record link check

This commit is contained in:
Yuri Kuznetsov
2023-02-06 22:58:02 +02:00
parent 3e945b7fd8
commit 6fcd0e155d
3 changed files with 375 additions and 273 deletions
@@ -0,0 +1,316 @@
<?php
/************************************************************************
* This file is part of EspoCRM.
*
* EspoCRM - Open Source CRM application.
* Copyright (C) 2014-2023 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko
* Website: https://www.espocrm.com
*
* EspoCRM is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* EspoCRM is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with EspoCRM. If not, see http://www.gnu.org/licenses/.
*
* The interactive user interfaces in modified source and object code versions
* of this program must display Appropriate Legal Notices, as required under
* Section 5 of the GNU General Public License version 3.
*
* In accordance with Section 7(b) of the GNU General Public License version 3,
* these Appropriate Legal Notices must retain the display of the "EspoCRM" word.
************************************************************************/
namespace Espo\Core\Record\Access;
use Espo\Core\Acl;
use Espo\Core\Acl\LinkChecker;
use Espo\Core\Acl\LinkChecker\LinkCheckerFactory;
use Espo\Core\Acl\Table as AclTable;
use Espo\Core\Exceptions\Error\Body as ErrorBody;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\ForbiddenSilent;
use Espo\Core\InjectableFactory;
use Espo\Core\Utils\Metadata;
use Espo\Entities\User;
use Espo\ORM\Defs\RelationDefs;
use Espo\ORM\Entity;
use Espo\ORM\EntityManager;
/**
* Check access for record linking.
*/
class LinkCheck
{
/** @var array<string, LinkChecker<Entity, Entity>>> */
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<Entity, Entity>
*/
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;
}
}
+27 -241
View File
@@ -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<string, ?LinkChecker<TEntity, Entity>> */
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<TEntity, Entity>
*/
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<TEntity, Entity> $checker */
$checker = $factory->create($this->entityType, $link);
$this->linkCheckerCache[$link] = $checker;
return $checker;
}
/**
* @throws Forbidden
* @throws NotFound
+32 -32
View File
@@ -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,