From 6fcd0e155dbf16689e8fc521832df3b791f25a99 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Mon, 6 Feb 2023 22:58:02 +0200 Subject: [PATCH] record link check --- .../Espo/Core/Record/Access/LinkCheck.php | 316 ++++++++++++++++++ application/Espo/Core/Record/Service.php | 268 ++------------- .../Espo/Tools/MassUpdate/Processor.php | 64 ++-- 3 files changed, 375 insertions(+), 273 deletions(-) create mode 100644 application/Espo/Core/Record/Access/LinkCheck.php diff --git a/application/Espo/Core/Record/Access/LinkCheck.php b/application/Espo/Core/Record/Access/LinkCheck.php new file mode 100644 index 0000000000..f2c83ec102 --- /dev/null +++ b/application/Espo/Core/Record/Access/LinkCheck.php @@ -0,0 +1,316 @@ +>> */ + private $linkCheckerCache = []; + + /** + * @param string[] $noEditAccessRequiredLinkList + */ + public function __construct( + private EntityManager $entityManager, + private Acl $acl, + private Metadata $metadata, + private InjectableFactory $injectableFactory, + private User $user, + private array $noEditAccessRequiredLinkList = [], + private bool $noEditAccessRequiredForLink = false + ) {} + + /** + * Checks relation fields set in an entity. + * + * @throws Forbidden + */ + public function process(Entity $entity): void + { + $this->processLinkMultiple($entity); + } + + /** + * @throws Forbidden + */ + private function processLinkMultiple(Entity $entity): void + { + $entityType = $entity->getEntityType(); + + $entityDefs = $this->entityManager + ->getDefs() + ->getEntity($entityType); + + $typeList = [ + Entity::HAS_MANY, + Entity::MANY_MANY, + Entity::HAS_CHILDREN, + ]; + + foreach ($entityDefs->getRelationList() as $relationDefs) { + $name = $relationDefs->getName(); + + if (!in_array($relationDefs->getType(), $typeList)) { + continue; + } + + $attribute = $name . 'Ids'; + + if ( + !$entityDefs->hasAttribute($attribute) || + !$entity->isAttributeChanged($attribute) + ) { + continue; + } + + /** @var string[] $ids */ + $ids = $entity->get($attribute) ?? []; + /** @var string[] $oldIds */ + $oldIds = $entity->getFetched($attribute) ?? []; + + $ids = array_values(array_diff($ids, $oldIds)); + $removedIds = array_values(array_diff($oldIds, $ids)); + + if ($ids === [] && $removedIds === []) { + continue; + } + + $hasLinkMultiple = + $entityDefs->hasField($name) && + $entityDefs->getField($name)->getType() === 'linkMultiple'; + + if ( + !$hasLinkMultiple && + !$this->acl->getScopeForbiddenLinkList($entityType, AclTable::ACTION_EDIT) + ) { + throw ForbiddenSilent::createWithBody( + "No access to link {$name}.", + ErrorBody::create() + ->withMessageTranslation('cannotRelateForbiddenLink', null, ['link' => $name]) + ->encode() + ); + } + + if ($ids === []) { + continue; + } + + foreach ($ids as $id) { + $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id); + } + } + } + + /** + * @throws Forbidden + */ + private function processLinkedRecordsCheckItem(Entity $entity, RelationDefs $defs, string $id): void + { + $entityType = $entity->getEntityType(); + $link = $defs->getName(); + + if (!$defs->hasForeignEntityType()) { + return; + } + + $foreignEntityType = $defs->getForeignEntityType(); + + $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $id); + + if (!$foreignEntity) { + throw ForbiddenSilent::createWithBody( + "Can't relate with non-existing record.", + ErrorBody::create() + ->withMessageTranslation( + 'cannotRelateNonExisting', null, ['foreignEntityType' => $foreignEntityType]) + ->encode() + ); + } + + $entityDefs = $this->entityManager->getDefs()->getEntity($entityType); + + $readAccess = + $entityDefs->hasField($link) && + $entityDefs->getField($link)->getType() === 'linkMultiple'; + + $this->linkForeignAccessCheck($entityType, $link, $foreignEntity, true, $readAccess); + $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + } + + /** + * Check access to a specific link. + * + * @throws Forbidden + */ + public function processLink(Entity $entity, string $link): void + { + $entityType = $entity->getEntityType(); + + /** @var AclTable::ACTION_*|null $action */ + $action = $this->metadata + ->get(['recordDefs', $entityType, 'relationships', $link, 'linkRequiredAccess']) ?? + null; + + if (!$action) { + $action = $this->noEditAccessRequiredForLink ? + AclTable::ACTION_READ : + AclTable::ACTION_EDIT; + } + + if (!$this->acl->check($entity, $action)) { + throw ForbiddenSilent::createWithBody( + "No record access for link operation ({$entityType}:{$link}).", + ErrorBody::create() + ->withMessageTranslation('noAccessToRecord', null, ['action' => $action]) + ->encode() + ); + } + } + + /** + * Check link access for a specific foreign entity. + * @throws Forbidden + */ + public function processLinkForeign(Entity $entity, string $link, Entity $foreignEntity): void + { + $this->linkForeignAccessCheck($entity->getEntityType(), $link, $foreignEntity); + $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + } + + /** + * @throws Forbidden + */ + private function linkForeignAccessCheck( + string $entityType, + string $link, + Entity $foreignEntity, + bool $fromUpdate = false, + bool $readAccess = false + ): void { + + $action = in_array($link, $this->noEditAccessRequiredLinkList) ? + AclTable::ACTION_READ : null; + + if (!$action) { + /** @var AclTable::ACTION_* $action */ + $action = $this->metadata + ->get(['recordDefs', $entityType, 'relationships', $link, 'linkRequiredForeignAccess']) ?? + AclTable::ACTION_EDIT; + } + + if ($readAccess) { + $action = AclTable::ACTION_READ; + } + + if ($this->acl->check($foreignEntity, $action)) { + return; + } + + $body = ErrorBody::create(); + + $body = $fromUpdate ? + $body->withMessageTranslation('cannotRelateForbidden', null, [ + 'foreignEntityType' => $foreignEntity->getEntityType(), + 'action' => $action, + ]) : + $body->withMessageTranslation('noAccessToForeignRecord', null, ['action' => $action]); + + throw ForbiddenSilent::createWithBody( + "No foreign record access for link operation ({$entityType}:{$link}).", + $body->encode() + ); + } + + /** + * @throws Forbidden + */ + private function linkEntityAccessCheck(Entity $entity, Entity $foreignEntity, string $link): void + { + $entityType = $entity->getEntityType(); + + $checker = $this->getLinkChecker($entityType, $link); + + if (!$checker) { + return; + } + + if ($checker->check($this->user, $entity, $foreignEntity)) { + return; + } + + throw ForbiddenSilent::createWithBody( + "No access for link operation ({$entityType}:{$link}).", + ErrorBody::create() + ->withMessageTranslation('noLinkAccess') + ->encode() + ); + } + + /** + * @return ?LinkChecker + */ + private function getLinkChecker(string $entityType, string $link): ?LinkChecker + { + $key = $entityType . '_' . $link; + + if (array_key_exists($key, $this->linkCheckerCache)) { + return $this->linkCheckerCache[$key]; + } + + $factory = $this->injectableFactory->create(LinkCheckerFactory::class); + + if (!$factory->isCreatable($entityType, $link)) { + return null; + } + + $checker = $factory->create($entityType, $link); + + $this->linkCheckerCache[$link] = $checker; + + return $checker; + } +} diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index 61d527eb2e..e67e08d2e2 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -29,8 +29,8 @@ namespace Espo\Core\Record; -use Espo\Core\Acl\LinkChecker; -use Espo\Core\Acl\LinkChecker\LinkCheckerFactory; +use Espo\Core\Binding\BindingContainerBuilder; +use Espo\Core\Binding\ContextualBinder; use Espo\Core\Exceptions\Conflict; use Espo\Core\ORM\Entity as CoreEntity; use Espo\Core\Exceptions\Error\Body as ErrorBody; @@ -40,8 +40,8 @@ use Espo\Core\Exceptions\Forbidden; use Espo\Core\Exceptions\ForbiddenSilent; use Espo\Core\Exceptions\NotFound; use Espo\Core\Exceptions\NotFoundSilent; +use Espo\Core\Record\Access\LinkCheck; use Espo\Entities\ActionHistoryRecord; -use Espo\ORM\Defs\RelationDefs; use Espo\ORM\Entity; use Espo\ORM\Repository\RDBRepository; use Espo\ORM\Collection; @@ -186,8 +186,7 @@ class Service implements Crud, private $listLoadProcessor = null; /** @var ?DuplicateFinder */ private $duplicateFinder = null; - /** @var array> */ - private $linkCheckerCache = []; + private ?LinkCheck $linkCheck = null; protected const MAX_SELECT_TEXT_ATTRIBUTE_LENGTH = 10000; @@ -377,120 +376,26 @@ class Service implements Crud, return $this->assignmentCheckerManager->check($this->user, $entity); } - /** - * @param TEntity $entity - * @throws Forbidden - * @todo Move to a separate class Access\LinkCheck. - */ - private function processLinkedRecordsCheck(Entity $entity): void + private function getLinkCheck(): LinkCheck { - $this->processLinkMultipleRecordsCheck($entity); - } - - /** - * @param TEntity $entity - * @throws Forbidden - */ - private function processLinkMultipleRecordsCheck(Entity $entity): void - { - $entityDefs = $this->entityManager - ->getDefs() - ->getEntity($this->entityType); - - $typeList = [ - Entity::HAS_MANY, - Entity::MANY_MANY, - Entity::HAS_CHILDREN, - ]; - - foreach ($entityDefs->getRelationList() as $relationDefs) { - $name = $relationDefs->getName(); - - if (!in_array($relationDefs->getType(), $typeList)) { - continue; - } - - $attribute = $name . 'Ids'; - - if ( - !$entityDefs->hasAttribute($attribute) || - !$entity->isAttributeChanged($attribute) - ) { - continue; - } - - /** @var string[] $ids */ - $ids = $entity->get($attribute) ?? []; - /** @var string[] $oldIds */ - $oldIds = $entity->getFetched($attribute) ?? []; - - $ids = array_values(array_diff($ids, $oldIds)); - $removedIds = array_values(array_diff($oldIds, $ids)); - - if ($ids === [] && $removedIds === []) { - continue; - } - - $hasLinkMultiple = - $entityDefs->hasField($name) && - $entityDefs->getField($name)->getType() === 'linkMultiple'; - - if ( - !$hasLinkMultiple && - !$this->acl->getScopeForbiddenLinkList($this->entityType, AclTable::ACTION_EDIT) - ) { - throw ForbiddenSilent::createWithBody( - "No access to link {$name}.", - ErrorBody::create() - ->withMessageTranslation('cannotRelateForbiddenLink', null, ['link' => $name]) - ->encode() - ); - } - - if ($ids === []) { - continue; - } - - foreach ($ids as $id) { - $this->processLinkedRecordsCheckItem($entity, $relationDefs, $id); - } - } - } - - /** - * @param TEntity $entity - * @throws Forbidden - */ - private function processLinkedRecordsCheckItem(Entity $entity, RelationDefs $defs, string $id): void - { - $link = $defs->getName(); - - if (!$defs->hasForeignEntityType()) { - return; - } - - $foreignEntityType = $defs->getForeignEntityType(); - - $foreignEntity = $this->entityManager->getEntityById($foreignEntityType, $id); - - if (!$foreignEntity) { - throw ForbiddenSilent::createWithBody( - "Can't relate with non-existing record.", - ErrorBody::create() - ->withMessageTranslation( - 'cannotRelateNonExisting', null, ['foreignEntityType' => $foreignEntityType]) - ->encode() + if (!$this->linkCheck) { + $linkCheck = $this->injectableFactory->createWithBinding( + LinkCheck::class, + BindingContainerBuilder::create() + ->bindInstance(Acl::class, $this->acl) + ->bindInstance(User::class, $this->user) + ->inContext(LinkCheck::class, function (ContextualBinder $binder) { + $binder + ->bindValue('$noEditAccessRequiredLinkList', $this->noEditAccessRequiredLinkList) + ->bindValue('$noEditAccessRequiredForLink', $this->noEditAccessRequiredForLink); + }) + ->build() ); + + $this->linkCheck = $linkCheck; } - $entityDefs = $this->entityManager->getDefs()->getEntity($this->entityType); - - $readAccess = - $entityDefs->hasField($link) && - $entityDefs->getField($link)->getType() === 'linkMultiple'; - - $this->linkForeignAccessCheck($link, $foreignEntity, true, $readAccess); - $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + return $this->linkCheck; } /** @@ -756,7 +661,7 @@ class Service implements Crud, $this->processValidation($entity, $data); $this->processAssignmentCheck($entity); - $this->processLinkedRecordsCheck($entity); + $this->getLinkCheck()->process($entity); if (!$params->skipDuplicateCheck()) { $this->processDuplicateCheck($entity, $data); @@ -782,7 +687,7 @@ class Service implements Crud, * * @return TEntity * @throws NotFound If record not found. - * @throws Forbidden If no access + * @throws Forbidden If no access. * @throws Conflict * @throws BadRequest */ @@ -822,7 +727,7 @@ class Service implements Crud, $this->processValidation($entity, $data); $this->processAssignmentCheck($entity); - $this->processLinkedRecordsCheck($entity); + $this->getLinkCheck()->process($entity); $checkForDuplicates = $this->metadata->get(['recordDefs', $this->entityType, 'updateDuplicateCheck']) ?? @@ -1144,7 +1049,7 @@ class Service implements Crud, throw new LogicException("Only core entities are supported."); } - $this->linkAccessCheck($entity, $link); + $this->getLinkCheck()->processLink($entity, $link); $methodName = 'link' . ucfirst($link); @@ -1166,8 +1071,7 @@ class Service implements Crud, throw new NotFound(); } - $this->linkForeignAccessCheck($link, $foreignEntity); - $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + $this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity); $this->recordHookManager->processBeforeLink($entity, $link, $foreignEntity); @@ -1205,7 +1109,7 @@ class Service implements Crud, throw new LogicException("Only core entities are supported."); } - $this->linkAccessCheck($entity, $link); + $this->getLinkCheck()->processLink($entity, $link); $methodName = 'unlink' . ucfirst($link); @@ -1227,8 +1131,7 @@ class Service implements Crud, throw new NotFound(); } - $this->linkForeignAccessCheck($link, $foreignEntity); - $this->linkEntityAccessCheck($entity, $foreignEntity, $link); + $this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity); $this->recordHookManager->processBeforeUnlink($entity, $link, $foreignEntity); @@ -1237,123 +1140,6 @@ class Service implements Crud, ->unrelate($foreignEntity); } - /** - * @param TEntity $entity - * @throws Forbidden - */ - private function linkAccessCheck(Entity $entity, string $link): void - { - /** @var AclTable::ACTION_*|null $action */ - $action = $this->metadata - ->get(['recordDefs', $this->entityType, 'relationships', $link, 'linkRequiredAccess']) ?? - null; - - if (!$action) { - $action = $this->noEditAccessRequiredForLink ? - AclTable::ACTION_READ : - AclTable::ACTION_EDIT; - } - - if (!$this->acl->check($entity, $action)) { - throw ForbiddenSilent::createWithBody( - "No record access for link operation ({$this->entityType}:{$link}).", - ErrorBody::create() - ->withMessageTranslation('noAccessToRecord', null, ['action' => $action]) - ->encode() - ); - } - } - - /** - * @throws Forbidden - */ - private function linkForeignAccessCheck( - string $link, - Entity $foreignEntity, - bool $fromUpdate = false, - bool $readAccess = false - ): void { - - $action = in_array($link, $this->noEditAccessRequiredLinkList) ? - AclTable::ACTION_READ : null; - - if (!$action) { - /** @var AclTable::ACTION_* $action */ - $action = $this->metadata - ->get(['recordDefs', $this->entityType, 'relationships', $link, 'linkRequiredForeignAccess']) ?? - AclTable::ACTION_EDIT; - } - - if ($readAccess) { - $action = AclTable::ACTION_READ; - } - - if ($this->acl->check($foreignEntity, $action)) { - return; - } - - $body = ErrorBody::create(); - - $body = $fromUpdate ? - $body->withMessageTranslation('cannotRelateForbidden', null, [ - 'foreignEntityType' => $foreignEntity->getEntityType(), - 'action' => $action, - ]) : - $body->withMessageTranslation('noAccessToForeignRecord', null, ['action' => $action]); - - throw ForbiddenSilent::createWithBody( - "No foreign record access for link operation ({$this->entityType}:{$link}).", - $body->encode() - ); - } - - /** - * @param TEntity $entity - * @throws Forbidden - */ - private function linkEntityAccessCheck(Entity $entity, Entity $foreignEntity, string $link): void - { - $checker = $this->getLinkChecker($link); - - if (!$checker) { - return; - } - - if ($checker->check($this->user, $entity, $foreignEntity)) { - return; - } - - throw ForbiddenSilent::createWithBody( - "No access for link operation ({$this->entityType}:{$link}).", - ErrorBody::create() - ->withMessageTranslation('noLinkAccess') - ->encode() - ); - } - - /** - * @return ?LinkChecker - */ - private function getLinkChecker(string $link): ?LinkChecker - { - if (array_key_exists($link, $this->linkCheckerCache)) { - return $this->linkCheckerCache[$link]; - } - - $factory = $this->injectableFactory->create(LinkCheckerFactory::class); - - if (!$factory->isCreatable($this->entityType, $link)) { - return null; - } - - /** @var LinkChecker $checker */ - $checker = $factory->create($this->entityType, $link); - - $this->linkCheckerCache[$link] = $checker; - - return $checker; - } - /** * @throws Forbidden * @throws NotFound diff --git a/application/Espo/Tools/MassUpdate/Processor.php b/application/Espo/Tools/MassUpdate/Processor.php index c12f61dc0f..c26180fa6d 100644 --- a/application/Espo/Tools/MassUpdate/Processor.php +++ b/application/Espo/Tools/MassUpdate/Processor.php @@ -29,12 +29,15 @@ namespace Espo\Tools\MassUpdate; +use Espo\Core\FieldProcessing\LinkMultiple\ListLoader as LinkMultipleLoader; +use Espo\Core\FieldProcessing\Loader\Params as LoaderParams; use Espo\Core\MassAction\QueryBuilder; use Espo\Core\MassAction\Params; use Espo\Core\MassAction\Result; use Espo\Core\Acl; use Espo\Core\Acl\Table; +use Espo\Core\Record\Access\LinkCheck; use Espo\Core\Record\ServiceFactory; use Espo\Core\Record\Service; @@ -54,40 +57,23 @@ use stdClass; class Processor { - private ValueMapPreparator $valueMapPreparator; - - private QueryBuilder $queryBuilder; - - private Acl $acl; - - private ServiceFactory $serviceFactory; - - private EntityManager $entityManager; - - private FieldUtil $fieldUtil; - - private User $user; - private const PERMISSION = 'massUpdatePermission'; public function __construct( - ValueMapPreparator $valueMapPreparator, - QueryBuilder $queryBuilder, - Acl $acl, - ServiceFactory $serviceFactory, - EntityManager $entityManager, - FieldUtil $fieldUtil, - User $user - ) { - $this->valueMapPreparator = $valueMapPreparator; - $this->queryBuilder = $queryBuilder; - $this->acl = $acl; - $this->serviceFactory = $serviceFactory; - $this->entityManager = $entityManager; - $this->fieldUtil = $fieldUtil; - $this->user = $user; - } + private ValueMapPreparator $valueMapPreparator, + private QueryBuilder $queryBuilder, + private Acl $acl, + private ServiceFactory $serviceFactory, + private EntityManager $entityManager, + private FieldUtil $fieldUtil, + private User $user, + private LinkCheck $linkCheck, + private LinkMultipleLoader $linkMultipleLoader + ) {} + /** + * @throws Forbidden + */ public function process(Params $params, Data $data): Result { $entityType = $params->getEntityType(); @@ -96,7 +82,7 @@ class Processor throw new Forbidden("No edit access for '{$entityType}'."); } - if ($this->acl->get(self::PERMISSION) !== Table::LEVEL_YES) { + if ($this->acl->getPermissionLevel(self::PERMISSION) !== Table::LEVEL_YES) { throw new Forbidden("No mass-update permission."); } @@ -174,12 +160,19 @@ class Processor $values = $this->prepareItemValueMap($entity, $data, $i, $fieldToCopyList); + // Needed for link check. + $this->linkMultipleLoader->process( + $entity, + LoaderParams::create() + ->withSelect($data->getAttributeList()) + ); + $entity->set($values); try { $service->processValidation($entity, $values); } - catch (Exception $e) { + catch (Exception) { return false; } @@ -187,6 +180,13 @@ class Processor return false; } + try { + $this->linkCheck->process($entity); + } + catch (Forbidden) { + return false; + } + $this->entityManager->saveEntity($entity, [ 'massUpdate' => true, 'skipStreamNotesAcl' => true,