diff --git a/application/Espo/Core/Record/Access/LinkCheck.php b/application/Espo/Core/Record/Access/LinkCheck.php index 671229a48c..84a64100eb 100644 --- a/application/Espo/Core/Record/Access/LinkCheck.php +++ b/application/Espo/Core/Record/Access/LinkCheck.php @@ -41,9 +41,11 @@ use Espo\Core\Utils\Metadata; use Espo\Entities\User; use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Defs; use Espo\ORM\Defs\RelationDefs; use Espo\ORM\Entity; use Espo\ORM\EntityManager; +use Espo\ORM\Type\RelationType; /** * Check access for record linking. @@ -57,6 +59,7 @@ class LinkCheck * @param string[] $noEditAccessRequiredLinkList */ public function __construct( + private Defs $ormDefs, private EntityManager $entityManager, private Acl $acl, private Metadata $metadata, @@ -234,6 +237,16 @@ class LinkCheck $this->linkEntityAccessCheck($entity, $foreignEntity, $link); } + /** + * Check unlink access for a specific foreign entity. + * @throws Forbidden + */ + public function processUnlinkForeign(Entity $entity, string $link, Entity $foreignEntity): void + { + $this->processLinkForeign($entity, $link, $foreignEntity); + $this->processUnlinkForeignRequired($entity, $link, $foreignEntity); + } + /** * @throws Forbidden */ @@ -369,4 +382,64 @@ class LinkCheck return $checker; } + + /** + * @throws Forbidden + */ + private function processUnlinkForeignRequired(Entity $entity, string $link, Entity $foreignEntity): void + { + $relationDefs = $this->ormDefs + ->getEntity($entity->getEntityType()) + ->tryGetRelation($link); + + if (!$relationDefs) { + return; + } + + if ( + !$relationDefs->hasForeignEntityType() || + !$relationDefs->hasForeignRelationName() + ) { + return; + } + + $foreignLink = $relationDefs->getForeignRelationName(); + + $foreignRelationDefs = $this->ormDefs + ->getEntity($foreignEntity->getEntityType()) + ->tryGetRelation($foreignLink); + + if (!$foreignRelationDefs) { + return; + } + + if ( + !in_array($foreignRelationDefs->getType(), [ + RelationType::BELONGS_TO, + RelationType::HAS_ONE, + RelationType::BELONGS_TO_PARENT, + ]) + ) { + return; + } + + $foreignFieldDefs = $this->ormDefs + ->getEntity($foreignEntity->getEntityType()) + ->tryGetField($foreignLink); + + if (!$foreignFieldDefs) { + return; + } + + if (!$foreignFieldDefs->getParam('required')) { + return; + } + + throw ForbiddenSilent::createWithBody( + "Can't unlink required field ({$foreignEntity->getEntityType()}:$foreignLink}).", + ErrorBody::create() + ->withMessageTranslation('cannotUnrelateRequiredLink') + ->encode() + ); + } } diff --git a/application/Espo/Core/Record/Service.php b/application/Espo/Core/Record/Service.php index 0d3ec66b4c..09cfcc9fd7 100644 --- a/application/Espo/Core/Record/Service.php +++ b/application/Espo/Core/Record/Service.php @@ -1158,7 +1158,7 @@ class Service implements Crud, throw new NotFound(); } - $this->getLinkCheck()->processLinkForeign($entity, $link, $foreignEntity); + $this->getLinkCheck()->processUnlinkForeign($entity, $link, $foreignEntity); $this->recordHookManager->processBeforeUnlink($entity, $link, $foreignEntity); diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 1de28c977a..8740d520a3 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -372,6 +372,7 @@ "noAccessToRecord": "Operation requires `{action}` access to record.", "noAccessToForeignRecord": "Operation requires `{action}` access to foreign record.", "noLinkAccess": "No access to link operation for a specific record.", + "cannotUnrelateRequiredLink": "Can't unrelate required link.", "cannotRelateNonExisting": "Can't relate with non-existing {foreignEntityType} record.", "cannotRelateForbidden": "Can't relate with forbidden {foreignEntityType} record. `{action}` access required.", "cannotRelateForbiddenLink": "No access to link '{link}'.", diff --git a/tests/integration/Espo/Record/LinkTest.php b/tests/integration/Espo/Record/LinkTest.php new file mode 100644 index 0000000000..16db44d6d9 --- /dev/null +++ b/tests/integration/Espo/Record/LinkTest.php @@ -0,0 +1,75 @@ +getContainer()->getByClass(Metadata::class); + + $metadata->set('entityDefs', CaseObj::ENTITY_TYPE, [ + 'fields' => [ + 'account' => ['required' => true] + ] + ]); + $metadata->save(); + + $this->reCreateApplication(); + + $em = $this->getContainer()->getByClass(EntityManager::class); + + $account = $em->createEntity(Account::ENTITY_TYPE, [ + 'name' => 'Test', + ]); + + $case = $em->createEntity(CaseObj::ENTITY_TYPE, [ + 'name' => 'Test', + 'accountId' => $account->getId(), + ]); + + $accountService = $this->getContainer() + ->getByClass(ServiceContainer::class) + ->getByClass(Account::class); + + $this->expectException(Forbidden::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $accountService->unlink($account->getId(), 'cases', $case->getId()); + } +}