From fddda800a8d75ada2844a195da63486bed0094bc Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Mon, 8 Jan 2024 18:07:07 +0200 Subject: [PATCH 1/5] year --- client/res/templates/site/footer.tpl | 2 +- html/main.html | 2 +- install/core/tpl/footer.tpl | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/res/templates/site/footer.tpl b/client/res/templates/site/footer.tpl index b8212b1a25..7c965686e9 100644 --- a/client/res/templates/site/footer.tpl +++ b/client/res/templates/site/footer.tpl @@ -1,4 +1,4 @@ -

© 2023 +

© 2024

diff --git a/install/core/tpl/footer.tpl b/install/core/tpl/footer.tpl index 01853321a5..1f7ec27ae2 100644 --- a/install/core/tpl/footer.tpl +++ b/install/core/tpl/footer.tpl @@ -1,2 +1,2 @@ -

© 2023 EspoCRM

+

© 2024 EspoCRM

From ee591c0142770af6f49ba6eb69a5bc23c9f03ee0 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 10 Jan 2024 10:05:44 +0200 Subject: [PATCH 2/5] doc --- schema/metadata/aclDefs.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schema/metadata/aclDefs.json b/schema/metadata/aclDefs.json index 38f286a526..6de9251542 100644 --- a/schema/metadata/aclDefs.json +++ b/schema/metadata/aclDefs.json @@ -30,7 +30,7 @@ "type": "string" }, "linkCheckerClassNameMap": { - "description": "Link checker classes for specific links.", + "description": "Link checker classes for specific links. Should implement `Espo\\Core\\Acl\\LinkChecker`.", "type": "object", "additionalProperties": { "type": "string" From 2d2d6f7fadde633cbb7540837758b4bb4ec3ce54 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 10 Jan 2024 13:27:55 +0200 Subject: [PATCH 3/5] fix test --- tests/integration/Espo/Record/SanitizeTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/integration/Espo/Record/SanitizeTest.php b/tests/integration/Espo/Record/SanitizeTest.php index cd5b8d63a5..c3f59fcc4d 100644 --- a/tests/integration/Espo/Record/SanitizeTest.php +++ b/tests/integration/Espo/Record/SanitizeTest.php @@ -69,6 +69,9 @@ class SanitizeTest extends BaseTestCase $numbers = $account->getPhoneNumberGroup()->getNumberList(); $this->assertCount(2, $numbers); + + sort($numbers); + $this->assertEquals('+380904443322', $numbers[0]); $this->assertEquals('+380904443333', $numbers[1]); } From 855ecf427dfb3c3e6d8d51ccc55b336d74954dde Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 10 Jan 2024 13:29:28 +0200 Subject: [PATCH 4/5] link checker for singlular links, checkers for case --- .../Espo/Core/Record/Access/LinkCheck.php | 84 ++++++--- .../Case/LinkCheckers/AccountLinkChecker.php | 91 ++++++++++ .../Case/LinkCheckers/ContactLinkChecker.php | 91 ++++++++++ .../Acl/Case/LinkCheckers/LeadLinkChecker.php | 91 ++++++++++ .../Crm/Resources/metadata/aclDefs/Case.json | 8 + .../Espo/Resources/i18n/en_US/Global.json | 2 +- tests/integration/Espo/Portal/AclTest.php | 84 ++++++++- tests/integration/Espo/Record/LinkTest.php | 168 ++++++++++++++++++ 8 files changed, 587 insertions(+), 32 deletions(-) create mode 100644 application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/AccountLinkChecker.php create mode 100644 application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/ContactLinkChecker.php create mode 100644 application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/LeadLinkChecker.php create mode 100644 application/Espo/Modules/Crm/Resources/metadata/aclDefs/Case.json diff --git a/application/Espo/Core/Record/Access/LinkCheck.php b/application/Espo/Core/Record/Access/LinkCheck.php index d26dff94a2..e1dc5c0064 100644 --- a/application/Espo/Core/Record/Access/LinkCheck.php +++ b/application/Espo/Core/Record/Access/LinkCheck.php @@ -51,7 +51,7 @@ use Espo\ORM\EntityManager; use Espo\ORM\Type\RelationType; /** - * Check access for record linking. + * Check access for record linking. When linking directly through relationships or via link fields. */ class LinkCheck { @@ -223,16 +223,32 @@ class LinkCheck ); } - if ($isOne) { - $this->linkForeignAccessCheckOne($entityType, $link, $foreignEntity); + $toSkip = $this->linkForeignAccessCheck($isOne, $entityType, $link, $foreignEntity); + if ($toSkip) { return; } - $this->linkForeignAccessCheck($entityType, $link, $foreignEntity, true); $this->linkEntityAccessCheck($entity, $foreignEntity, $link); } + /** + * @throws Forbidden + */ + private function linkForeignAccessCheck( + bool $isOne, + string $entityType, + string $link, + Entity $foreignEntity + ): bool { + + if ($isOne) { + return $this->linkForeignAccessCheckOne($entityType, $link, $foreignEntity); + } + + return $this->linkForeignAccessCheckMany($entityType, $link, $foreignEntity, true); + } + private function getParam(string $entityType, string $link, string $param): mixed { return $this->metadata->get(['recordDefs', $entityType, 'relationships', $link, $param]); @@ -283,7 +299,12 @@ class LinkCheck */ public function processLinkForeign(Entity $entity, string $link, Entity $foreignEntity): void { - $this->linkForeignAccessCheck($entity->getEntityType(), $link, $foreignEntity); + $toSkip = $this->linkForeignAccessCheckMany($entity->getEntityType(), $link, $foreignEntity); + + if ($toSkip) { + return; + } + $this->linkEntityAccessCheck($entity, $foreignEntity, $link); } @@ -299,14 +320,17 @@ class LinkCheck } /** + * Check access to foreign record for has-many and many-many links. + * + * @return bool True indicates that the link checker should be bypassed. * @throws Forbidden */ - private function linkForeignAccessCheck( + private function linkForeignAccessCheckMany( string $entityType, string $link, Entity $foreignEntity, bool $fromUpdate = false - ): void { + ): bool { $action = in_array($link, $this->noEditAccessRequiredLinkList) ? AclTable::ACTION_READ : @@ -318,7 +342,7 @@ class LinkCheck } if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) { - return; + return true; } $fieldDefs = $fromUpdate ? @@ -336,19 +360,23 @@ class LinkCheck $action = AclTable::ACTION_READ; if ($this->checkInDefaults($fieldDefs, $link, $foreignEntity)) { - return; + return true; } } - if ($this->acl->check($foreignEntity, $action)) { - return; - } - if ( $action === AclTable::ACTION_READ && $this->checkIsAllowedForPortal($foreignEntity) ) { - return; + return true; + } + + if ($this->acl->check($foreignEntity, $action)) { + return false; + } + + if ($this->getLinkChecker($entityType, $link)) { + return false; } $body = ErrorBody::create(); @@ -409,8 +437,10 @@ class LinkCheck throw ForbiddenSilent::createWithBody( "No access for link operation ($entityType:$link).", ErrorBody::create() - ->withMessageTranslation('noLinkAccess') - ->encode() + ->withMessageTranslation('noLinkAccess', null, [ + 'foreignEntityType' => $foreignEntity->getEntityType(), + 'link' => $link, + ]) ); } @@ -537,12 +567,15 @@ class LinkCheck } /** + * Check access to foreign record for belongs-to, has-one and belongs-to-parent links. + * + * @return bool True indicates that the link checker should be bypassed. * @throws Forbidden */ - private function linkForeignAccessCheckOne(string $entityType, string $link, Entity $foreignEntity): void + private function linkForeignAccessCheckOne(string $entityType, string $link, Entity $foreignEntity): bool { if ($this->getParam($entityType, $link, 'linkForeignAccessCheckDisabled')) { - return; + return true; } $fieldDefs = $this->entityManager @@ -555,16 +588,20 @@ class LinkCheck in_array($fieldDefs->getType(), $this->oneFieldTypeList) ) { if ($this->checkIsDefault($fieldDefs, $link, $foreignEntity)) { - return; + return true; } } - if ($this->acl->check($foreignEntity, AclTable::ACTION_READ)) { - return; + if ($this->checkIsAllowedForPortal($foreignEntity)) { + return true; } - if ($this->checkIsAllowedForPortal($foreignEntity)) { - return; + if ($this->acl->check($foreignEntity, AclTable::ACTION_READ)) { + return false; + } + + if ($this->getLinkChecker($entityType, $link)) { + return false; } throw ForbiddenSilent::createWithBody( @@ -590,7 +627,6 @@ class LinkCheck { return $foreignEntity->getId() === $this->getDefault($fieldDefs, $link . 'Id'); } - private function getDefault(FieldDefs $fieldDefs, string $attribute): mixed { $defaultAttributes = (object) ($fieldDefs->getParam('defaultAttributes') ?? []); diff --git a/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/AccountLinkChecker.php b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/AccountLinkChecker.php new file mode 100644 index 0000000000..e1bb71b9d4 --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/AccountLinkChecker.php @@ -0,0 +1,91 @@ +. + * + * 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 Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Modules\Crm\Classes\Acl\Case\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\Account; +use Espo\Modules\Crm\Entities\CaseObj; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class AccountLinkChecker implements LinkChecker +{ + public function __construct( + private AclManager $aclManager, + private EntityManager $entityManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + if (!$entity->isNew()) { + return false; + } + + $emailIds = $entity->getLinkMultipleIdList('emails'); + + if (count($emailIds) === 0 || count($emailIds) > 1) { + return false; + } + + $email = $this->entityManager + ->getRepositoryByClass(Email::class) + ->getById($emailIds[0]); + + if (!$email) { + return false; + } + + $parent = $email->getParent(); + + if (!$parent) { + return false; + } + + if ( + $parent->getEntityType() !== Account::ENTITY_TYPE || + $parent->getId() !== $foreignEntity->getId() + ) { + return false; + } + + return $this->aclManager->checkEntityRead($user, $email); + } +} diff --git a/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/ContactLinkChecker.php b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/ContactLinkChecker.php new file mode 100644 index 0000000000..8ac4c2b156 --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/ContactLinkChecker.php @@ -0,0 +1,91 @@ +. + * + * 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 Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Modules\Crm\Classes\Acl\Case\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\CaseObj; +use Espo\Modules\Crm\Entities\Contact; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class ContactLinkChecker implements LinkChecker +{ + public function __construct( + private AclManager $aclManager, + private EntityManager $entityManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + if (!$entity->isNew()) { + return false; + } + + $emailIds = $entity->getLinkMultipleIdList('emails'); + + if (count($emailIds) === 0 || count($emailIds) > 1) { + return false; + } + + $email = $this->entityManager + ->getRepositoryByClass(Email::class) + ->getById($emailIds[0]); + + if (!$email) { + return false; + } + + $parent = $email->getParent(); + + if (!$parent) { + return false; + } + + if ( + $parent->getEntityType() !== Contact::ENTITY_TYPE || + $parent->getId() !== $foreignEntity->getId() + ) { + return false; + } + + return $this->aclManager->checkEntityRead($user, $email); + } +} diff --git a/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/LeadLinkChecker.php b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/LeadLinkChecker.php new file mode 100644 index 0000000000..97263b8ed1 --- /dev/null +++ b/application/Espo/Modules/Crm/Classes/Acl/Case/LinkCheckers/LeadLinkChecker.php @@ -0,0 +1,91 @@ +. + * + * 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 Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Modules\Crm\Classes\Acl\Case\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\Modules\Crm\Entities\CaseObj; +use Espo\Modules\Crm\Entities\Lead; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class LeadLinkChecker implements LinkChecker +{ + public function __construct( + private AclManager $aclManager, + private EntityManager $entityManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + if (!$entity->isNew()) { + return false; + } + + $emailIds = $entity->getLinkMultipleIdList('emails'); + + if (count($emailIds) === 0 || count($emailIds) > 1) { + return false; + } + + $email = $this->entityManager + ->getRepositoryByClass(Email::class) + ->getById($emailIds[0]); + + if (!$email) { + return false; + } + + $parent = $email->getParent(); + + if (!$parent) { + return false; + } + + if ( + $parent->getEntityType() !== Lead::ENTITY_TYPE || + $parent->getId() !== $foreignEntity->getId() + ) { + return false; + } + + return $this->aclManager->checkEntityRead($user, $email); + } +} diff --git a/application/Espo/Modules/Crm/Resources/metadata/aclDefs/Case.json b/application/Espo/Modules/Crm/Resources/metadata/aclDefs/Case.json new file mode 100644 index 0000000000..43f27a3d2e --- /dev/null +++ b/application/Espo/Modules/Crm/Resources/metadata/aclDefs/Case.json @@ -0,0 +1,8 @@ +{ + "linkCheckerClassNameMap": { + "lead": "Espo\\Modules\\Crm\\Classes\\Acl\\Case\\LinkCheckers\\LeadLinkChecker", + "account": "Espo\\Modules\\Crm\\Classes\\Acl\\Case\\LinkCheckers\\AccountLinkChecker", + "contact": "Espo\\Modules\\Crm\\Classes\\Acl\\Case\\LinkCheckers\\ContactLinkChecker", + "contacts": "Espo\\Modules\\Crm\\Classes\\Acl\\Case\\LinkCheckers\\ContactLinkChecker" + } +} diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index 5ab6d64099..c1ae4b5f94 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -376,7 +376,7 @@ "loggedOutLeaveOut": "Logged out. The session is inactive. You may lose unsaved form data after page refresh. You may need to make a copy.", "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.", + "noLinkAccess": "Can't relate with {foreignEntity} record through the link {link}. No access.", "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.", diff --git a/tests/integration/Espo/Portal/AclTest.php b/tests/integration/Espo/Portal/AclTest.php index c6b0beb30e..11e312b477 100644 --- a/tests/integration/Espo/Portal/AclTest.php +++ b/tests/integration/Espo/Portal/AclTest.php @@ -29,13 +29,17 @@ namespace tests\integration\Espo\Portal; -use Espo\Core\{ - Select\SearchParams, -}; +use Espo\Core\Exceptions\Forbidden; +use Espo\Core\Record\CreateParams; +use Espo\Core\Record\Service; +use Espo\Core\Record\ServiceContainer; +use Espo\Core\Select\SearchParams; +use Espo\Modules\Crm\Entities\CaseObj; +use tests\integration\Core\BaseTestCase; -class AclTest extends \tests\integration\Core\BaseTestCase +class AclTest extends BaseTestCase { - public function testAccessContact() + public function testAccessContact(): void { $app = $this->createApplication(); @@ -101,7 +105,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase $this->assertFalse(in_array($case3->id, $idList)); } - public function testAccessAccount() + public function testAccessAccount(): void { $app = $this->createApplication(); @@ -173,7 +177,7 @@ class AclTest extends \tests\integration\Core\BaseTestCase $this->assertTrue(in_array($case4->id, $idList)); } - public function testAccessOwn() + public function testAccessOwn(): void { $app = $this->createApplication(); @@ -252,4 +256,70 @@ class AclTest extends \tests\integration\Core\BaseTestCase $this->assertFalse(in_array($case4->id, $idList)); $this->assertTrue(in_array($case5->id, $idList)); } + + public function testCreateCase(): void + { + $em = $this->getEntityManager(); + + $contact = $em->createEntity('Contact'); + $account = $em->createEntity('Account'); + + $contactNotOwn = $em->createEntity('Contact'); + $accountNotOwn = $em->createEntity('Account'); + + $portal = $em->createEntity('Portal', [ + 'name' => 'Portal', + ]); + + $this->createUser([ + 'userName' => 'tester', + 'portalsIds' => [$portal->getId()], + 'contactId' => $contact->getId(), + 'accountsIds' => [$account->getId()], + ], [ + 'data' => [ + 'Case' => [ + 'create' => 'yes', + 'read' => 'own', + 'edit' => 'no', + 'delete' => 'no', + 'stream' => 'own', + ] + ], + ], true); + + $this->auth('tester', null, $portal->getId()); + + $app = $this->createApplication(true, $portal->getId()); + + /** @var Service $caseService */ + $caseService = $app->getContainer() + ->getByClass(ServiceContainer::class) + ->getByClass(CaseObj::class); + + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => 'Test 1', + 'accountId' => $account->getId(), + 'contactId' => $contact->getId(), + 'contactsIds' => [$contact->getId()], + ], CreateParams::create()); + + $isThrown = false; + + try { + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object)[ + 'name' => 'Test 1', + 'accountId' => $accountNotOwn->getId(), + 'contactId' => $contactNotOwn->getId(), + 'contactsIds' => [$contactNotOwn->getId()], + ], CreateParams::create()); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + } } diff --git a/tests/integration/Espo/Record/LinkTest.php b/tests/integration/Espo/Record/LinkTest.php index b43137e40f..828a01f05f 100644 --- a/tests/integration/Espo/Record/LinkTest.php +++ b/tests/integration/Espo/Record/LinkTest.php @@ -35,8 +35,11 @@ use Espo\Core\Record\CreateParams; use Espo\Core\Record\ServiceContainer; use Espo\Core\Record\UpdateParams; use Espo\Core\Utils\Metadata; +use Espo\Entities\Email; use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\CaseObj; +use Espo\Modules\Crm\Entities\Contact; +use Espo\Modules\Crm\Entities\Lead; use Espo\Modules\Crm\Entities\Opportunity; use Espo\Modules\Crm\Entities\Task; use Espo\ORM\EntityManager; @@ -232,4 +235,169 @@ class LinkTest extends BaseTestCase 'closeDate' => Date::createToday()->toString(), ], CreateParams::create()); } + + public function testLinkCheckEmailToCase(): void + { + $user = $this->createUser('test', [ + 'data' => [ + 'Account' => [ + 'create' => 'no', + 'read' => 'own', + 'edit' => 'no', + 'delete' => 'no', + ], + 'Case' => [ + 'create' => 'yes', + 'read' => 'own', + 'edit' => 'own', + 'delete' => 'no', + ], + 'Contact' => [ + 'create' => 'yes', + 'read' => 'own', + 'edit' => 'own', + 'delete' => 'no', + ], + 'Lead' => [ + 'create' => 'yes', + 'read' => 'own', + 'edit' => 'own', + 'delete' => 'no', + ], + 'Email' => [ + 'create' => 'yes', + 'read' => 'own', + 'edit' => 'own', + 'delete' => 'no', + ], + ], + ]); + + $em = $this->getEntityManager(); + + $account = $em->createEntity(Account::ENTITY_TYPE, ['assignedUserId' => $user->getId()]); + $lead = $em->createEntity(Lead::ENTITY_TYPE); + $contact = $em->createEntity(Contact::ENTITY_TYPE); + $email = $em->createEntity(Email::ENTITY_TYPE, [ + 'assignedUserId' => $user->getId(), + 'parentId' => $lead->getId(), + 'parentType' => Lead::ENTITY_TYPE, + ]); + + $this->auth('test'); + $this->reCreateApplication(); + + $caseService = $this->getContainer() + ->getByClass(ServiceContainer::class) + ->getByClass(CaseObj::class); + + // Should not create if no access to the Lead. + + $isThrown = false; + + try { + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'leadId' => $lead->getId(), + 'accountId' => $account->getId(), + ], CreateParams::create()); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + + // Should create if an email with a Lead parent is passed. + + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'leadId' => $lead->getId(), + 'accountId' => $account->getId(), + 'emailsIds' => [$email->getId()] + ], CreateParams::create()); + + // + + $account = $em->createEntity(Account::ENTITY_TYPE); + $email = $em->createEntity(Email::ENTITY_TYPE, [ + 'assignedUserId' => $user->getId(), + 'parentId' => $account->getId(), + 'parentType' => Account::ENTITY_TYPE, + ]); + + // Should not create if no access to the Account. + + $isThrown = false; + + try { + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'accountId' => $account->getId(), + ], CreateParams::create()); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + + // Should create if an email with an Account parent is passed. + + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'accountId' => $account->getId(), + 'emailsIds' => [$email->getId()] + ], CreateParams::create()); + + // + + $account = $em->createEntity(Account::ENTITY_TYPE, ['assignedUserId' => $user->getId()]); + + $email = $em->createEntity(Email::ENTITY_TYPE, [ + 'assignedUserId' => $user->getId(), + 'parentId' => $contact->getId(), + 'parentType' => Contact::ENTITY_TYPE, + ]); + + // Should not create if no access to the Contact. + + $isThrown = false; + + try { + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'accountId' => $account->getId(), + 'contactId' => $contact->getId(), + 'contactsIds' => [$contact->getId()], + ], CreateParams::create()); + } + catch (Forbidden) { + $isThrown = true; + } + + $this->assertTrue($isThrown); + + // Should create if an email with a Contact parent is passed. + + /** @noinspection PhpUnhandledExceptionInspection */ + $caseService->create((object) [ + 'name' => '1', + 'assignedUserId' => $user->getId(), + 'accountId' => $account->getId(), + 'contactId' => $contact->getId(), + 'contactsIds' => [$contact->getId()], + 'emailsIds' => [$email->getId()] + ], CreateParams::create()); + } } From 9cee152e2354aa77379a85af4b89679cc45bbc67 Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Wed, 10 Jan 2024 13:56:37 +0200 Subject: [PATCH 5/5] email reply link checker --- .../Email/LinkCheckers/ParentLinkChecker.php | 80 +++++++++++++++++++ .../Espo/Resources/i18n/en_US/Global.json | 2 +- .../Resources/metadata/aclDefs/Email.json | 5 +- 3 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php diff --git a/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php b/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php new file mode 100644 index 0000000000..865497eea6 --- /dev/null +++ b/application/Espo/Classes/Acl/Email/LinkCheckers/ParentLinkChecker.php @@ -0,0 +1,80 @@ +. + * + * 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 Affero General Public License version 3. + * + * In accordance with Section 7(b) of the GNU Affero General Public License version 3, + * these Appropriate Legal Notices must retain the display of the "EspoCRM" word. + ************************************************************************/ + +namespace Espo\Classes\Acl\Email\LinkCheckers; + +use Espo\Core\Acl\LinkChecker; +use Espo\Core\AclManager; +use Espo\Entities\Email; +use Espo\Entities\User; +use Espo\ORM\Entity; +use Espo\ORM\EntityManager; + +/** + * @implements LinkChecker + * @noinspection PhpUnused + */ +class ParentLinkChecker implements LinkChecker +{ + public function __construct( + private EntityManager $entityManager, + private AclManager $aclManager + ) {} + + public function check(User $user, Entity $entity, Entity $foreignEntity): bool + { + if ($this->aclManager->checkEntityRead($user, $foreignEntity)) { + return true; + } + + if (!$entity->getReplied()) { + return false; + } + + $replied = $this->entityManager + ->getRepositoryByClass(Email::class) + ->getById($entity->getReplied()->getId()); + + if (!$replied) { + return false; + } + + $parentLink = $replied->getParent(); + + if ( + !$parentLink || + $parentLink->getId() !== $foreignEntity->getId() || + $parentLink->getEntityType() !== $foreignEntity->getEntityType() + ) { + return false; + } + + return $this->aclManager->checkEntityRead($user, $replied); + } +} diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index c1ae4b5f94..ca330becdd 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -376,7 +376,7 @@ "loggedOutLeaveOut": "Logged out. The session is inactive. You may lose unsaved form data after page refresh. You may need to make a copy.", "noAccessToRecord": "Operation requires `{action}` access to record.", "noAccessToForeignRecord": "Operation requires `{action}` access to foreign record.", - "noLinkAccess": "Can't relate with {foreignEntity} record through the link {link}. No access.", + "noLinkAccess": "Can't relate with {foreignEntityType} record through the link '{link}'. No access.", "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.", diff --git a/application/Espo/Resources/metadata/aclDefs/Email.json b/application/Espo/Resources/metadata/aclDefs/Email.json index 8fe35a3411..7663998b9a 100644 --- a/application/Espo/Resources/metadata/aclDefs/Email.json +++ b/application/Espo/Resources/metadata/aclDefs/Email.json @@ -4,5 +4,8 @@ "portalAccessCheckerClassName": "Espo\\Classes\\AclPortal\\Email\\AccessChecker", "portalOwnershipCheckerClassName": "Espo\\Classes\\AclPortal\\Email\\OwnershipChecker", "assignmentCheckerClassName": "Espo\\Classes\\Acl\\Email\\AssignmentChecker", - "readOwnerUserField": "users" + "readOwnerUserField": "users", + "linkCheckerClassNameMap": { + "parent": "Espo\\Classes\\Acl\\Email\\LinkCheckers\\ParentLinkChecker" + } }