From 089fc98fdebeee5e911d24be270a9e18a60b0caf Mon Sep 17 00:00:00 2001 From: Yuri Kuznetsov Date: Fri, 25 Oct 2024 17:25:40 +0300 Subject: [PATCH] collaborators --- .../DefaultLayouts/DefaultSidePanelType.php | 20 ++- application/Espo/Core/Acl.php | 22 ++- .../Core/Acl/AccessChecker/ScopeChecker.php | 6 + .../Acl/AccessChecker/ScopeCheckerData.php | 8 +- .../AccessChecker/ScopeCheckerDataBuilder.php | 36 +++- .../Espo/Core/Acl/DefaultAccessChecker.php | 16 +- .../Core/Acl/DefaultAssignmentChecker.php | 91 +++++++--- .../Espo/Core/Acl/DefaultOwnershipChecker.php | 24 ++- .../Espo/Core/Acl/OwnershipSharedChecker.php | 48 ++++++ application/Espo/Core/AclManager.php | 30 +++- .../Espo/Core/Console/Commands/AclCheck.php | 7 + .../ExtGroup/AclGroup/CheckEntityType.php | 2 + application/Espo/Core/Portal/AclManager.php | 11 +- .../Select/AccessControl/Filters/OnlyOwn.php | 49 +++++- .../Select/AccessControl/Filters/OnlyTeam.php | 17 ++ .../Espo/Core/Select/Bool/Filters/Shared.php | 85 ++++++++++ .../Espo/Core/Select/Helpers/FieldHelper.php | 23 ++- .../Select/Helpers/RelationQueryHelper.php | 29 +++- .../Orm/LinkConverters/EntityCollaborator.php | 68 ++++++++ application/Espo/Entities/User.php | 1 + .../Espo/Hooks/Common/Collaborators.php | 142 ++++++++++++++++ .../Crm/Resources/layouts/Case/filters.json | 13 +- .../Crm/Resources/layouts/Task/filters.json | 15 +- .../metadata/app/entityManagerParams.json | 72 ++++++++ .../Resources/metadata/entityAcl/Case.json | 3 + .../Resources/metadata/entityAcl/Task.json | 5 + .../Resources/i18n/en_US/EntityManager.json | 4 +- .../Espo/Resources/i18n/en_US/Global.json | 5 +- .../Resources/metadata/app/aclPortal.json | 1 + .../Resources/metadata/app/entityManager.json | 3 +- .../metadata/app/entityManagerParams.json | 28 +++ .../Resources/metadata/app/relationships.json | 3 + .../Hook/Hooks/CollaboratorsUpdateHook.php | 128 ++++++++++++++ .../Espo/Tools/EntityManager/NameUtil.php | 1 + .../Espo/Tools/FieldManager/FieldManager.php | 1 + .../Espo/Tools/Stream/HookProcessor.php | 3 +- .../Tools/Stream/RecordService/Helper.php | 4 +- application/Espo/Tools/Stream/Service.php | 67 +++++--- client/src/acl-manager.js | 13 +- client/src/acl.js | 85 +++++++--- .../src/helpers/model/defaults-populator.js | 9 + client/src/views/admin/field-manager/edit.js | 2 +- .../fields/records/bool-filter-list.js | 4 + client/src/views/fields/collaborators.js | 78 +++++++++ client/src/views/record/search.js | 4 + client/src/views/role/record/table.js | 28 ++- schema/metadata/app/acl.json | 4 +- schema/metadata/scopes.json | 4 + tests/integration/Espo/User/AclTest.php | 159 ++++++++++++++++++ .../Espo/Core/Acl/ScopeCheckerDataTest.php | 38 +++-- 50 files changed, 1376 insertions(+), 143 deletions(-) create mode 100644 application/Espo/Core/Acl/OwnershipSharedChecker.php create mode 100644 application/Espo/Core/Select/Bool/Filters/Shared.php create mode 100644 application/Espo/Core/Utils/Database/Orm/LinkConverters/EntityCollaborator.php create mode 100644 application/Espo/Hooks/Common/Collaborators.php create mode 100644 application/Espo/Tools/EntityManager/Hook/Hooks/CollaboratorsUpdateHook.php create mode 100644 client/src/views/fields/collaborators.js diff --git a/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php b/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php index 577c7aeb78..29302df1bc 100644 --- a/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php +++ b/application/Espo/Classes/DefaultLayouts/DefaultSidePanelType.php @@ -29,19 +29,18 @@ namespace Espo\Classes\DefaultLayouts; +use Espo\Core\ORM\Type\FieldType; use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use stdClass; class DefaultSidePanelType { - private $metadata; - - public function __construct(Metadata $metadata) - { - $this->metadata = $metadata; - } + public function __construct(private Metadata $metadata) + {} /** - * @return \stdClass[] + * @return stdClass[] */ public function get(string $scope): array { @@ -64,6 +63,13 @@ class DefaultSidePanelType $list[] = (object) ['name' => 'teams']; } + if ( + $this->metadata->get("entityDefs.$scope.fields.collaborators.type") === FieldType::LINK_MULTIPLE && + $this->metadata->get("entityDefs.$scope.links.collaborators.entity") === User::ENTITY_TYPE + ) { + $list[] = (object) ['name' => 'collaborators']; + } + return $list; } } diff --git a/application/Espo/Core/Acl.php b/application/Espo/Core/Acl.php index c31dddba2a..bd3aef9b11 100644 --- a/application/Espo/Core/Acl.php +++ b/application/Espo/Core/Acl.php @@ -113,9 +113,9 @@ class Acl * whether a scope level is set to 'enabled'. * * @param string|Entity $subject An entity type or entity. - * @param string|null $action Action to check. Constants are available in the `Table` class. - * + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. * @throws NotImplemented + * @noinspection PhpDocSignatureInspection */ public function check($subject, ?string $action = null): bool { @@ -126,7 +126,8 @@ class Acl * The same as `check` but does not throw NotImplemented exception. * * @param string|Entity $subject An entity type or entity. - * @param string|null $action Action to check. Constants are available in the `Table` class. + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection */ public function tryCheck($subject, ?string $action = null): bool { @@ -148,7 +149,8 @@ class Acl * Check access to a specific entity. * * @param Entity $entity An entity to check. - * @param string $action Action to check. Constants are available in the `Table` class. + * @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection */ public function checkEntity(Entity $entity, string $action = Table::ACTION_READ): bool { @@ -212,6 +214,18 @@ class Acl return $this->aclManager->checkOwnershipTeam($this->user, $entity); } + /** + * Check whether an entity is shared with a user. + * + * @param Table::ACTION_* $action + * @since 8.5.0 + * @noinspection PhpDocSignatureInspection + */ + public function checkOwnershipShared(Entity $entity, string $action): bool + { + return $this->aclManager->checkOwnershipShared($this->user, $entity, $action); + } + /** * Get attributes forbidden for a user. * diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php b/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php index 33eb3f9e0b..a36872fde3 100644 --- a/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php +++ b/application/Espo/Core/Acl/AccessChecker/ScopeChecker.php @@ -74,6 +74,12 @@ class ScopeChecker } } + if ($level === Table::LEVEL_OWN || $level === Table::LEVEL_TEAM) { + if ($checkerData->isShared()) { + return true; + } + } + if ($level === Table::LEVEL_TEAM) { if ($checkerData->inTeam()) { return true; diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php index 1cdf6cf72b..9546c6fb03 100644 --- a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php +++ b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerData.php @@ -38,7 +38,8 @@ class ScopeCheckerData { public function __construct( private Closure $isOwnChecker, - private Closure $inTeamChecker + private Closure $inTeamChecker, + private Closure $isSharedChecker, ) {} public function isOwn(): bool @@ -51,6 +52,11 @@ class ScopeCheckerData return ($this->inTeamChecker)(); } + public function isShared(): bool + { + return ($this->isSharedChecker)(); + } + public static function createBuilder(): ScopeCheckerDataBuilder { return new ScopeCheckerDataBuilder(); diff --git a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php index 834b6a9497..540a7ced06 100644 --- a/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php +++ b/application/Espo/Core/Acl/AccessChecker/ScopeCheckerDataBuilder.php @@ -38,16 +38,13 @@ class ScopeCheckerDataBuilder { private Closure $isOwnChecker; private Closure $inTeamChecker; + private Closure $isSharedChecker; public function __construct() { - $this->isOwnChecker = function (): bool { - return false; - }; - - $this->inTeamChecker = function (): bool { - return false; - }; + $this->isOwnChecker = fn(): bool => false; + $this->inTeamChecker = fn(): bool => false; + $this->isSharedChecker = fn(): bool => false; } public function setIsOwn(bool $value): self @@ -84,6 +81,19 @@ class ScopeCheckerDataBuilder return $this; } + public function setIsShared(bool $value): self + { + if ($value) { + $this->isSharedChecker = fn(): bool => true; + + return $this; + } + + $this->isSharedChecker = fn(): bool => false; + + return $this; + } + /** * @param Closure(): bool $checker */ @@ -104,8 +114,18 @@ class ScopeCheckerDataBuilder return $this; } + /** + * @param Closure(): bool $checker + */ + public function setIsSharedChecker(Closure $checker): self + { + $this->isSharedChecker = $checker; + + return $this; + } + public function build(): ScopeCheckerData { - return new ScopeCheckerData($this->isOwnChecker, $this->inTeamChecker); + return new ScopeCheckerData($this->isOwnChecker, $this->inTeamChecker, $this->isSharedChecker); } } diff --git a/application/Espo/Core/Acl/DefaultAccessChecker.php b/application/Espo/Core/Acl/DefaultAccessChecker.php index 17a1bf83f7..74c058abc4 100644 --- a/application/Espo/Core/Acl/DefaultAccessChecker.php +++ b/application/Espo/Core/Acl/DefaultAccessChecker.php @@ -68,19 +68,22 @@ class DefaultAccessChecker implements private ScopeChecker $scopeChecker ) {} + /** + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ private function checkEntity(User $user, Entity $entity, ScopeData $data, string $action): bool { $checkerData = ScopeCheckerData ::createBuilder() ->setIsOwnChecker( - function () use ($user, $entity): bool { - return $this->aclManager->checkOwnershipOwn($user, $entity); - } + fn(): bool => $this->aclManager->checkOwnershipOwn($user, $entity) ) ->setInTeamChecker( - function () use ($user, $entity): bool { - return $this->aclManager->checkOwnershipTeam($user, $entity); - } + fn(): bool => $this->aclManager->checkOwnershipTeam($user, $entity) + ) + ->setIsSharedChecker( + fn(): bool => $this->aclManager->checkOwnershipShared($user, $entity, $action) ) ->build(); @@ -93,6 +96,7 @@ class DefaultAccessChecker implements ::createBuilder() ->setIsOwn(true) ->setInTeam(true) + ->setIsShared(true) ->build(); return $this->scopeChecker->check($data, $action, $checkerData); diff --git a/application/Espo/Core/Acl/DefaultAssignmentChecker.php b/application/Espo/Core/Acl/DefaultAssignmentChecker.php index 65501e1be3..135f53f135 100644 --- a/application/Espo/Core/Acl/DefaultAssignmentChecker.php +++ b/application/Espo/Core/Acl/DefaultAssignmentChecker.php @@ -30,6 +30,8 @@ namespace Espo\Core\Acl; use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Metadata; use Espo\Repositories\User as UserRepository; use Espo\ORM\Defs; use Espo\ORM\Entity; @@ -45,12 +47,13 @@ class DefaultAssignmentChecker implements AssignmentChecker protected const FIELD_ASSIGNED_USERS = 'assignedUsers'; protected const FIELD_TEAMS = 'teams'; protected const ATTR_ASSIGNED_USER_ID = 'assignedUserId'; - protected const ATTR_ASSIGNED_USERS_IDS = 'assignedUsersIds'; + private const FIELD_COLLABORATORS = 'collaborators'; public function __construct( private AclManager $aclManager, private EntityManager $entityManager, - private Defs $ormDefs + private Defs $ormDefs, + private Metadata $metadata, ) {} public function check(User $user, Entity $entity): bool @@ -69,18 +72,37 @@ class DefaultAssignmentChecker implements AssignmentChecker } } + if ($this->hasCollaboratorsField($entity->getEntityType())) { + if (!$this->isPermittedUsers($user, $entity, self::FIELD_COLLABORATORS)) { + return false; + } + } + return true; } + private function hasCollaboratorsField(string $entityType): bool + { + if (!$this->metadata->get("scopes.$entityType.collaborators")) { + return false; + } + + $entityDefs = $this->ormDefs->getEntity($entityType); + + return + $entityDefs->tryGetField(self::FIELD_COLLABORATORS)?->getType() === FieldType::LINK_MULTIPLE && + $entityDefs->tryGetRelation(self::FIELD_COLLABORATORS)?->tryGetForeignEntityType() === User::ENTITY_TYPE; + } + private function hasAssignedUsersField(string $entityType): bool { $entityDefs = $this->ormDefs->getEntity($entityType); return $entityDefs->hasField(self::FIELD_ASSIGNED_USERS) && - $entityDefs->getField(self::FIELD_ASSIGNED_USERS)->getType() === 'linkMultiple' && + $entityDefs->getField(self::FIELD_ASSIGNED_USERS)->getType() === FieldType::LINK_MULTIPLE && $entityDefs->hasRelation(self::FIELD_ASSIGNED_USERS) && - $entityDefs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === 'User'; + $entityDefs->getRelation(self::FIELD_ASSIGNED_USERS)->getForeignEntityType() === User::ENTITY_TYPE; } protected function isPermittedAssignedUser(User $user, Entity $entity): bool @@ -237,18 +259,28 @@ class DefaultAssignmentChecker implements AssignmentChecker return true; } + /** + * Left for backward compatibility. + */ protected function isPermittedAssignedUsers(User $user, Entity $entity): bool + { + return $this->isPermittedUsers($user, $entity, self::FIELD_ASSIGNED_USERS); + } + + private function isPermittedUsers(User $user, Entity $entity, string $field): bool { if (!$entity instanceof CoreEntity) { return true; } - if (!$entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) { + $idsAttr = $field . 'Ids'; + + if (!$entity->hasLinkMultipleField($field)) { return true; } if ($user->isPortal()) { - if (!$entity->isAttributeChanged(self::ATTR_ASSIGNED_USERS_IDS)) { + if (!$entity->isAttributeChanged($idsAttr)) { return true; } @@ -261,6 +293,10 @@ class DefaultAssignmentChecker implements AssignmentChecker $assignmentPermission === Table::LEVEL_YES || !in_array($assignmentPermission, [Table::LEVEL_TEAM, Table::LEVEL_NO]) ) { + if (!$this->hasOnlyInternalUsers($entity, $field)) { + return false; + } + return true; } @@ -268,22 +304,22 @@ class DefaultAssignmentChecker implements AssignmentChecker if (!$entity->isNew()) { // Might be on purpose. - $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + $entity->getLinkMultipleIdList($field); - if ($entity->isAttributeChanged(self::ATTR_ASSIGNED_USERS_IDS)) { + if ($entity->isAttributeChanged($idsAttr)) { $toProcess = true; } } else { $toProcess = true; } - $userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + $userIdList = $entity->getLinkMultipleIdList($field); if (!$toProcess) { return true; } - if (empty($userIdList)) { + if ($userIdList === []) { if ($assignmentPermission === Table::LEVEL_NO && !$user->isApi()) { return false; } @@ -292,22 +328,23 @@ class DefaultAssignmentChecker implements AssignmentChecker } if ($assignmentPermission === Table::LEVEL_NO) { - return $this->isPermittedAssignedUsersLevelNo($user, $entity); + return $this->isPermittedUsersLevelNo($user, $entity, $field); } if ($assignmentPermission === Table::LEVEL_TEAM) { - return $this->isPermittedAssignedUsersLevelTeam($user, $entity); + return $this->isPermittedUsersLevelTeam($user, $entity, $field); } /** @phpstan-ignore-next-line */ return true; } - private function isPermittedAssignedUsersLevelNo(User $user, CoreEntity $entity): bool + private function isPermittedUsersLevelNo(User $user, CoreEntity $entity, string $field): bool { - $userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); + $idsAttr = $field . 'Ids'; - $fetchedAssignedUserIdList = $entity->getFetched(self::ATTR_ASSIGNED_USERS_IDS); + $userIdList = $entity->getLinkMultipleIdList($field); + $fetchedAssignedUserIdList = $entity->getFetched($idsAttr); foreach ($userIdList as $userId) { if (!$entity->isNew() && in_array($userId, $fetchedAssignedUserIdList)) { @@ -322,12 +359,12 @@ class DefaultAssignmentChecker implements AssignmentChecker return true; } - private function isPermittedAssignedUsersLevelTeam(User $user, CoreEntity $entity): bool + private function isPermittedUsersLevelTeam(User $user, CoreEntity $entity, string $field): bool { - $userIdList = $entity->getLinkMultipleIdList(self::FIELD_ASSIGNED_USERS); - - $fetchedAssignedUserIdList = $entity->getFetched(self::ATTR_ASSIGNED_USERS_IDS); + $idsAttr = $field . 'Ids'; + $userIdList = $entity->getLinkMultipleIdList($field); + $fetchedAssignedUserIdList = $entity->getFetched($idsAttr); $teamIdList = $user->getLinkMultipleIdList(self::FIELD_TEAMS); foreach ($userIdList as $userId) { @@ -344,4 +381,20 @@ class DefaultAssignmentChecker implements AssignmentChecker return true; } + + private function hasOnlyInternalUsers(CoreEntity $entity, string $field): bool + { + $count = $this->entityManager + ->getRDBRepositoryByClass(User::class) + ->where([ + 'type!=' => [ + User::TYPE_REGULAR, + User::TYPE_ADMIN, + ], + 'id' => $entity->getLinkMultipleIdList($field), + ]) + ->count(); + + return $count === 0; + } } diff --git a/application/Espo/Core/Acl/DefaultOwnershipChecker.php b/application/Espo/Core/Acl/DefaultOwnershipChecker.php index 4685754a7d..fc97aafdc7 100644 --- a/application/Espo/Core/Acl/DefaultOwnershipChecker.php +++ b/application/Espo/Core/Acl/DefaultOwnershipChecker.php @@ -38,14 +38,16 @@ use Espo\Entities\User; * * @implements OwnershipOwnChecker * @implements OwnershipTeamChecker + * @implements OwnershipSharedChecker */ -class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker +class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamChecker, OwnershipSharedChecker { private const ATTR_CREATED_BY_ID = 'createdById'; private const ATTR_ASSIGNED_USER_ID = 'assignedUserId'; private const ATTR_ASSIGNED_TEAMS_IDS = 'teamsIds'; private const FIELD_TEAMS = 'teams'; private const FIELD_ASSIGNED_USERS = 'assignedUsers'; + private const FIELD_COLLABORATORS = 'collaborators'; public function checkOwn(User $user, Entity $entity): bool { @@ -109,4 +111,24 @@ class DefaultOwnershipChecker implements OwnershipOwnChecker, OwnershipTeamCheck return false; } + + public function checkShared(User $user, Entity $entity, string $action): bool + { + if (!$entity instanceof CoreEntity) { + return false; + } + + if ($action !== Table::ACTION_READ && $action !== Table::ACTION_STREAM) { + return false; + } + + if ( + !$entity->hasRelation(self::FIELD_COLLABORATORS) || + !$entity->hasLinkMultipleField(self::FIELD_COLLABORATORS) + ) { + return false; + } + + return in_array($user->getId(), $entity->getLinkMultipleIdList(self::FIELD_COLLABORATORS)); + } } diff --git a/application/Espo/Core/Acl/OwnershipSharedChecker.php b/application/Espo/Core/Acl/OwnershipSharedChecker.php new file mode 100644 index 0000000000..4b5804fc32 --- /dev/null +++ b/application/Espo/Core/Acl/OwnershipSharedChecker.php @@ -0,0 +1,48 @@ +. + * + * 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\Core\Acl; + +use Espo\ORM\Entity; +use Espo\Entities\User; + +/** + * @template TEntity of Entity + */ +interface OwnershipSharedChecker extends OwnershipChecker +{ + /** + * Check whether an entity is shared with a user. + * + * @param TEntity $entity + * @param Table::ACTION_* $action + * @noinspection PhpDocSignatureInspection + */ + public function checkShared(User $user, Entity $entity, string $action): bool; +} diff --git a/application/Espo/Core/AclManager.php b/application/Espo/Core/AclManager.php index ee5518222c..6fe204d801 100644 --- a/application/Espo/Core/AclManager.php +++ b/application/Espo/Core/AclManager.php @@ -29,6 +29,7 @@ namespace Espo\Core; +use Espo\Core\Acl\OwnershipSharedChecker; use Espo\Core\Acl\Permission; use Espo\ORM\Entity; use Espo\ORM\EntityManager; @@ -246,9 +247,9 @@ class AclManager * * @param User $user A user to check for. * @param string|Entity $subject An entity type or entity. - * @param string|null $action Action to check. Constants are available in the `Table` class. - * + * @param Table::ACTION_*|null $action $action Action to check. Constants are available in the `Table` class. * @throws NotImplemented + * @noinspection PhpDocSignatureInspection */ public function check(User $user, $subject, ?string $action = null): bool { @@ -273,7 +274,8 @@ class AclManager * * @param User $user A user to check for. * @param string|Entity $subject An entity type or entity. - * @param string|null $action Action to check. Constants are available in the `Table` class. + * @param Table::ACTION_*|null $action Action to check. Constants are available in the `Table` class. + * @noinspection PhpDocSignatureInspection */ public function tryCheck(User $user, $subject, ?string $action = null): bool { @@ -289,9 +291,9 @@ class AclManager * * @param User $user A user to check for. * @param Entity $entity An entity to check. - * @param string $action Action to check. Constants are available in the `Table` class. - * + * @param Table::ACTION_* $action Action to check. Constants are available in the `Table` class. * @throws NotImplemented + * @noinspection PhpDocSignatureInspection */ public function checkEntity(User $user, Entity $entity, string $action = Table::ACTION_READ): bool { @@ -405,6 +407,24 @@ class AclManager return $checker->checkTeam($user, $entity); } + /** + * Check whether an entity is shared with a user. + * + * @param Table::ACTION_* $action + * @since 8.5.0 + * @noinspection PhpDocSignatureInspection + */ + public function checkOwnershipShared(User $user, Entity $entity, string $action): bool + { + $checker = $this->getOwnershipChecker($entity->getEntityType()); + + if (!$checker instanceof OwnershipSharedChecker) { + return false; + } + + return $checker->checkShared($user, $entity, $action); + } + /** * Check access to scope. If $action is omitted, it will check whether a scope level is set to 'enabled'. * diff --git a/application/Espo/Core/Console/Commands/AclCheck.php b/application/Espo/Core/Console/Commands/AclCheck.php index 7e9ae934d6..343ac9f5cb 100644 --- a/application/Espo/Core/Console/Commands/AclCheck.php +++ b/application/Espo/Core/Console/Commands/AclCheck.php @@ -37,6 +37,7 @@ use Espo\Core\Console\Command\Params; use Espo\Core\Console\IO; use Espo\Core\Container; use Espo\Core\Portal\Application as PortalApplication; +use Espo\Core\Acl\Table; /** * Checks access for websocket topic subscription. Prints `true` if access allowed. @@ -55,6 +56,8 @@ class AclCheck implements Command $userId = $options['userId'] ?? null; $scope = $options['scope'] ?? null; $id = $options['id'] ?? null; + + /** @var Table::ACTION_*|null $action */ $action = $options['action'] ?? null; if (!$userId || !$scope || !$id) { @@ -102,6 +105,10 @@ class AclCheck implements Command } } + /** + * @param Table::ACTION_*|null $action + * @noinspection PhpDocSignatureInspection + */ private function check( User $user, string $scope, diff --git a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php index e1d9ea58b4..ccc20067c8 100644 --- a/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php +++ b/application/Espo/Core/Formula/Functions/ExtGroup/AclGroup/CheckEntityType.php @@ -57,6 +57,8 @@ class CheckEntityType implements Func $userId = $arguments[0]; $entityType = $arguments[1]; $id = $arguments[2]; + + /** @var Table::ACTION_*|null $action */ $action = $arguments[3] ?? Table::ACTION_READ; if (!is_string($userId)) { diff --git a/application/Espo/Core/Portal/AclManager.php b/application/Espo/Core/Portal/AclManager.php index b5b61a735a..4258fcd297 100644 --- a/application/Espo/Core/Portal/AclManager.php +++ b/application/Espo/Core/Portal/AclManager.php @@ -232,13 +232,22 @@ class AclManager extends InternalAclManager return parent::checkOwnershipOwn($user, $entity); } + public function checkOwnershipShared(User $user, Entity $entity, string $action): bool + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->checkOwnershipShared($user, $entity, $action); + } + + return false; + } + public function checkOwnershipTeam(User $user, Entity $entity): bool { if ($this->checkUserIsNotPortal($user)) { return $this->internalAclManager->checkOwnershipTeam($user, $entity); } - return parent::checkOwnershipOwn($user, $entity); + return false; } /** diff --git a/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php b/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php index c9fe4d33c5..810e9d2fe5 100644 --- a/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php +++ b/application/Espo/Core/Select/AccessControl/Filters/OnlyOwn.php @@ -33,6 +33,9 @@ use Espo\Core\Select\AccessControl\Filter; use Espo\Core\Select\Helpers\RelationQueryHelper; use Espo\Core\Select\Helpers\FieldHelper; use Espo\Entities\User; +use Espo\ORM\Query\Part\Where\OrGroup; +use Espo\ORM\Query\Part\WhereClause; +use Espo\ORM\Query\Part\WhereItem; use Espo\ORM\Query\SelectBuilder as QueryBuilder; class OnlyOwn implements Filter @@ -46,22 +49,52 @@ class OnlyOwn implements Filter public function apply(QueryBuilder $queryBuilder): void { - if ($this->fieldHelper->hasAssignedUsersField()) { - $queryBuilder->where( - $this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId()) - ); + $ownItem = $this->getOwnWhereItem(); + + if ($this->fieldHelper->hasCollaboratorsField()) { + $this->applyCollaborators($queryBuilder, $ownItem); return; } + if (!$ownItem) { + $queryBuilder->where(['id' => null]); + + return; + } + + $queryBuilder->where($ownItem); + } + + private function applyCollaborators(QueryBuilder $queryBuilder, ?WhereItem $ownItem): void + { + $sharedItem = $this->relationQueryHelper->prepareCollaboratorsWhere($this->entityType, $this->user->getId()); + + $orBuilder = OrGroup::createBuilder(); + + if ($ownItem) { + $orBuilder->add($ownItem); + } + + $orBuilder->add($sharedItem); + + $queryBuilder->where($orBuilder->build()); + } + + private function getOwnWhereItem(): ?WhereItem + { + if ($this->fieldHelper->hasAssignedUsersField()) { + return $this->relationQueryHelper->prepareAssignedUsersWhere($this->entityType, $this->user->getId()); + } + if ($this->fieldHelper->hasAssignedUserField()) { - $queryBuilder->where(['assignedUserId' => $this->user->getId()]); - - return; + return WhereClause::fromRaw(['assignedUserId' => $this->user->getId()]); } if ($this->fieldHelper->hasCreatedByField()) { - $queryBuilder->where(['createdById' => $this->user->getId()]); + return WhereClause::fromRaw(['createdById' => $this->user->getId()]); } + + return null; } } diff --git a/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php b/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php index 1598ceb89d..74741a8cc8 100644 --- a/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php +++ b/application/Espo/Core/Select/AccessControl/Filters/OnlyTeam.php @@ -86,6 +86,23 @@ class OnlyTeam implements Filter $orGroup['createdById'] = $this->user->getId(); } + if ($this->fieldHelper->hasCollaboratorsField()) { + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation('collaborators'); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $subQueryBuilder->leftJoin($middleEntityType, 'collaboratorsMiddle', [ + "collaboratorsMiddle.$key1:" => 'id', + 'collaboratorsMiddle.deleted' => false, + ]); + + $orGroup["collaboratorsMiddle.$key2"] = $this->user->getId(); + } + $subQuery = $subQueryBuilder ->where(['OR' => $orGroup]) ->build(); diff --git a/application/Espo/Core/Select/Bool/Filters/Shared.php b/application/Espo/Core/Select/Bool/Filters/Shared.php new file mode 100644 index 0000000000..e8ac94458a --- /dev/null +++ b/application/Espo/Core/Select/Bool/Filters/Shared.php @@ -0,0 +1,85 @@ +. + * + * 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\Core\Select\Bool\Filters; + +use Espo\Core\Select\Bool\Filter; +use Espo\Core\Select\Helpers\FieldHelper; +use Espo\Entities\User; +use Espo\ORM\Defs; +use Espo\ORM\Query\Part\Condition as Cond; +use Espo\ORM\Query\Part\Where\OrGroupBuilder; +use Espo\ORM\Query\SelectBuilder as QueryBuilder; + +/** + * @noinspection PhpUnused + */ +class Shared implements Filter +{ + public const NAME = 'shared'; + + public function __construct( + private string $entityType, + private User $user, + private FieldHelper $fieldHelper, + private Defs $defs + ) {} + + public function apply(QueryBuilder $queryBuilder, OrGroupBuilder $orGroupBuilder): void + { + if (!$this->fieldHelper->hasCollaboratorsField()) { + return; + } + + $relationDefs = $this->defs + ->getEntity($this->entityType) + ->getRelation('collaborators'); + + $middleEntityType = ucfirst($relationDefs->getRelationshipName()); + $key1 = $relationDefs->getMidKey(); + $key2 = $relationDefs->getForeignMidKey(); + + $subQuery = QueryBuilder::create() + ->select('id') + ->from($this->entityType) + ->leftJoin($middleEntityType, 'collaboratorsMiddle', [ + "collaboratorsMiddle.$key1:" => 'id', + 'collaboratorsMiddle.deleted' => false, + ]) + ->where(["collaboratorsMiddle.$key2" => $this->user->getId()]) + ->build(); + + $orGroupBuilder->add( + Cond::in( + Cond::column('id'), + $subQuery + ) + ); + } +} diff --git a/application/Espo/Core/Select/Helpers/FieldHelper.php b/application/Espo/Core/Select/Helpers/FieldHelper.php index d004689dca..3cafbcd08f 100644 --- a/application/Espo/Core/Select/Helpers/FieldHelper.php +++ b/application/Espo/Core/Select/Helpers/FieldHelper.php @@ -29,6 +29,7 @@ namespace Espo\Core\Select\Helpers; +use Espo\Core\Utils\Metadata; use Espo\Entities\Team; use Espo\Entities\User; use Espo\Modules\Crm\Entities\Account; @@ -55,9 +56,13 @@ class FieldHelper private const LINK_ASSIGNED_USERS = 'assignedUsers'; private const LINK_ASSIGNED_USER = 'assignedUser'; private const LINK_CREATED_BY = 'createdBy'; + private const LINK_COLLABORATORS = 'collaborators'; - public function __construct(private string $entityType, private EntityManager $entityManager) - {} + public function __construct( + private string $entityType, + private EntityManager $entityManager, + private Metadata $metadata, + ) {} private function getSeed(): Entity { @@ -77,6 +82,20 @@ class FieldHelper return false; } + public function hasCollaboratorsField(): bool + { + if ( + $this->metadata->get("scopes.$this->entityType.collaborators") && + $this->getSeed()->hasRelation(self::LINK_COLLABORATORS) && + $this->getSeed()->hasAttribute(self::LINK_COLLABORATORS . 'Ids') && + $this->getRelationEntityType(self::LINK_COLLABORATORS) === User::ENTITY_TYPE + ) { + return true; + } + + return false; + } + public function hasAssignedUserField(): bool { if ( diff --git a/application/Espo/Core/Select/Helpers/RelationQueryHelper.php b/application/Espo/Core/Select/Helpers/RelationQueryHelper.php index 91eff9b031..334d398799 100644 --- a/application/Espo/Core/Select/Helpers/RelationQueryHelper.php +++ b/application/Espo/Core/Select/Helpers/RelationQueryHelper.php @@ -47,9 +47,34 @@ class RelationQueryHelper public function prepareAssignedUsersWhere(string $entityType, string $userId): WhereItem { + return $this->prepareRelatedUsersWhere( + $entityType, + $userId, + 'assignedUsers', + User::RELATIONSHIP_ENTITY_USER + ); + } + + public function prepareCollaboratorsWhere(string $entityType, string $userId): WhereItem + { + return $this->prepareRelatedUsersWhere( + $entityType, + $userId, + 'collaborators', + User::RELATIONSHIP_ENTITY_COLLABORATOR + ); + } + + private function prepareRelatedUsersWhere( + string $entityType, + string $userId, + string $field, + string $relationship + ): WhereItem { + $relationDefs = $this->defs ->getEntity($entityType) - ->getRelation('assignedUsers'); + ->getRelation($field); $middleEntityType = ucfirst($relationDefs->getRelationshipName()); $key1 = $relationDefs->getMidKey(); @@ -60,7 +85,7 @@ class RelationQueryHelper 'm.deleted' => false, ]; - if ($middleEntityType === User::RELATIONSHIP_ENTITY_USER) { + if ($middleEntityType === $relationship) { $joinWhere['m.entityType'] = $entityType; } diff --git a/application/Espo/Core/Utils/Database/Orm/LinkConverters/EntityCollaborator.php b/application/Espo/Core/Utils/Database/Orm/LinkConverters/EntityCollaborator.php new file mode 100644 index 0000000000..5e989e721a --- /dev/null +++ b/application/Espo/Core/Utils/Database/Orm/LinkConverters/EntityCollaborator.php @@ -0,0 +1,68 @@ +. + * + * 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\Core\Utils\Database\Orm\LinkConverters; + +use Espo\Core\Utils\Database\Orm\Defs\AttributeDefs; +use Espo\Core\Utils\Database\Orm\Defs\EntityDefs; +use Espo\Core\Utils\Database\Orm\Defs\RelationDefs; +use Espo\Core\Utils\Database\Orm\LinkConverter; +use Espo\Entities\User; +use Espo\ORM\Defs\RelationDefs as LinkDefs; +use Espo\ORM\Type\AttributeType; +use Espo\ORM\Type\RelationType; + +/** + * @noinspection PhpUnused + */ +class EntityCollaborator implements LinkConverter +{ + private const ENTITY_TYPE_LENGTH = 100; + + public function convert(LinkDefs $linkDefs, string $entityType): EntityDefs + { + $name = $linkDefs->getName(); + $relationshipName = $linkDefs->getRelationshipName(); + + return EntityDefs::create() + ->withRelation( + RelationDefs::create($name) + ->withType(RelationType::MANY_MANY) + ->withForeignEntityType(User::ENTITY_TYPE) + ->withRelationshipName($relationshipName) + ->withMidKeys('entityId', 'userId') + ->withConditions(['entityType' => $entityType]) + ->withAdditionalColumn( + AttributeDefs::create('entityType') + ->withType(AttributeType::VARCHAR) + ->withLength(self::ENTITY_TYPE_LENGTH) + ) + ); + } +} diff --git a/application/Espo/Entities/User.php b/application/Espo/Entities/User.php index 48e27c242a..7fe32015a3 100644 --- a/application/Espo/Entities/User.php +++ b/application/Espo/Entities/User.php @@ -59,6 +59,7 @@ class User extends Person public const TYPE_SUPER_ADMIN = 'super-admin'; public const RELATIONSHIP_ENTITY_USER = 'EntityUser'; + public const RELATIONSHIP_ENTITY_COLLABORATOR = 'EntityCollaborator'; public function isActive(): bool { diff --git a/application/Espo/Hooks/Common/Collaborators.php b/application/Espo/Hooks/Common/Collaborators.php new file mode 100644 index 0000000000..4eb84bd04d --- /dev/null +++ b/application/Espo/Hooks/Common/Collaborators.php @@ -0,0 +1,142 @@ +. + * + * 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\Hooks\Common; + +use Espo\Core\Field\Link; +use Espo\Core\Field\LinkMultiple; +use Espo\Core\Field\LinkMultipleItem; +use Espo\Core\Hook\Hook\BeforeSave; +use Espo\Core\ORM\Entity as CoreEntity; +use Espo\Core\Utils\Metadata; +use Espo\ORM\Entity; +use Espo\ORM\Repository\Option\SaveOptions; + +/** + * @implements BeforeSave + */ +class Collaborators implements BeforeSave +{ + public static int $order = 7; + + private const FIELD_COLLABORATORS = 'collaborators'; + private const FIELD_ASSIGNED_USERS = 'assignedUsers'; + private const FIELD_ASSIGNED_USER = 'assignedUser'; + + public function __construct( + private Metadata $metadata, + ) {} + + public function beforeSave(Entity $entity, SaveOptions $options): void + { + if (!$entity instanceof CoreEntity) { + return; + } + + if (!$this->hasCollaborators($entity)) { + return; + } + + if ($entity->hasLinkMultipleField(self::FIELD_ASSIGNED_USERS)) { + $this->processAssignedUsers($entity); + + return; + } + + $this->processAssignedUser($entity); + } + + private function hasCollaborators(CoreEntity $entity): bool + { + if (!$this->metadata->get("scopes.{$entity->getEntityType()}.collaborators")) { + return false; + } + + if (!$entity->hasLinkMultipleField(self::FIELD_COLLABORATORS)) { + return false; + } + + return true; + } + + private function processAssignedUsers(CoreEntity $entity): void + { + if (!$entity->has(self::FIELD_COLLABORATORS . 'Ids')) { + return; + } + + $assignedUsers = $entity->getValueObject(self::FIELD_ASSIGNED_USERS); + $collaborators = $entity->getValueObject(self::FIELD_COLLABORATORS); + + if ( + !$assignedUsers instanceof LinkMultiple || + !$collaborators instanceof LinkMultiple + ) { + return; + } + + $countBefore = $collaborators->getCount(); + + foreach ($assignedUsers->getList() as $assignedUser) { + $collaborators = $collaborators->withAdded($assignedUser); + } + + if ($countBefore === $collaborators->getCount()) { + return; + } + + $entity->setValueObject(self::FIELD_COLLABORATORS, $collaborators); + } + + private function processAssignedUser(CoreEntity $entity): void + { + $idAttr = self::FIELD_ASSIGNED_USER . 'Id'; + + if (!$entity->hasAttribute($idAttr) || !$entity->isAttributeChanged($idAttr)) { + return; + } + + $assignedUser = $entity->getValueObject(self::FIELD_ASSIGNED_USER); + + if (!$assignedUser instanceof Link) { + return; + } + + $collaborators = $entity->getValueObject(self::FIELD_COLLABORATORS); + + if (!$collaborators instanceof LinkMultiple) { + return; + } + + $collaborators = $collaborators + ->withAdded(LinkMultipleItem::create($assignedUser->getId(), $assignedUser->getName())); + + $entity->setValueObject(self::FIELD_COLLABORATORS, $collaborators); + } +} diff --git a/application/Espo/Modules/Crm/Resources/layouts/Case/filters.json b/application/Espo/Modules/Crm/Resources/layouts/Case/filters.json index 6a6dd48a29..cc103220fd 100644 --- a/application/Espo/Modules/Crm/Resources/layouts/Case/filters.json +++ b/application/Espo/Modules/Crm/Resources/layouts/Case/filters.json @@ -1,14 +1,15 @@ [ "assignedUser", "teams", - "createdAt", - "createdBy", - "modifiedAt", + "collaborators", + "status", + "priority", "account", "contact", "number", + "createdAt", + "createdBy", + "modifiedAt", "modifiedAt", - "status", - "priority", "description" -] \ No newline at end of file +] diff --git a/application/Espo/Modules/Crm/Resources/layouts/Task/filters.json b/application/Espo/Modules/Crm/Resources/layouts/Task/filters.json index 621c91831b..b14d612e31 100644 --- a/application/Espo/Modules/Crm/Resources/layouts/Task/filters.json +++ b/application/Espo/Modules/Crm/Resources/layouts/Task/filters.json @@ -1,14 +1,15 @@ [ "assignedUser", "teams", - "createdAt", - "createdBy", - "modifiedAt", + "collaborators", + "status", + "priority", + "parent", "account", - "dateCompleted", "dateStart", "dateEnd", - "status", - "parent", - "priority" + "dateCompleted", + "createdAt", + "createdBy", + "modifiedAt" ] diff --git a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json index 57e0e6f96f..e2a8cb9a7c 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Modules/Crm/Resources/metadata/app/entityManagerParams.json @@ -15,6 +15,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -39,6 +46,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -63,6 +77,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -72,6 +93,13 @@ } }, "Opportunity": { + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -81,6 +109,13 @@ } }, "Document": { + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -90,6 +125,13 @@ } }, "Case": { + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -99,6 +141,13 @@ } }, "KnowledgeBaseArticle": { + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -202,6 +251,13 @@ "view": "crm:views/admin/entity-manager/fields/status-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -210,6 +266,15 @@ } } }, + "TargetList": { + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + } + }, "@Event": { "activityStatusList": { "location": "scopes", @@ -247,6 +312,13 @@ "view": "crm:views/admin/entity-manager/fields/status-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Case.json b/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Case.json index 4837f36257..f3c1ad3612 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Case.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Case.json @@ -7,6 +7,9 @@ "links": { "inboundEmail": { "readOnly": true + }, + "collaborators": { + "readOnly": true } } } diff --git a/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Task.json b/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Task.json index d1d51f10ff..6c6c655266 100644 --- a/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Task.json +++ b/application/Espo/Modules/Crm/Resources/metadata/entityAcl/Task.json @@ -3,5 +3,10 @@ "email": { "readOnly": true } + }, + "links": { + "collaborators": { + "readOnly": true + } } } diff --git a/application/Espo/Resources/i18n/en_US/EntityManager.json b/application/Espo/Resources/i18n/en_US/EntityManager.json index ee4dc5fba4..94460990b3 100644 --- a/application/Espo/Resources/i18n/en_US/EntityManager.json +++ b/application/Espo/Resources/i18n/en_US/EntityManager.json @@ -52,7 +52,8 @@ "module": "Module", "version": "Version", "primaryFilters": "Primary Filters", - "assignedUsers": "Multiple Assigned Users" + "assignedUsers": "Multiple Assigned Users", + "collaborators": "Collaborators" }, "options": { "type": { @@ -95,6 +96,7 @@ "beforeSaveApiScript": "A script called on create and update API requests before an entity is saved. Use for custom validation and duplicate checking." }, "tooltips": { + "collaborators": "The ability to share records with specific users.", "assignedUsers": "The ability to assign multiple users to a record.\n\nNote that after enabling the parameter, existing assigned users won't be transferred to the new *Assigned Users* field.", "duplicateCheckFieldList": "Which fields to check when performing checking for duplicates.", "updateDuplicateCheck": "Perform checking for duplicates when updating a record.", diff --git a/application/Espo/Resources/i18n/en_US/Global.json b/application/Espo/Resources/i18n/en_US/Global.json index df41e82559..a2f616ac44 100644 --- a/application/Espo/Resources/i18n/en_US/Global.json +++ b/application/Espo/Resources/i18n/en_US/Global.json @@ -428,7 +428,8 @@ "boolFilters": { "onlyMy": "Only My", "onlyMyTeam": "My Team", - "followed": "Followed" + "followed": "Followed", + "shared": "Shared" }, "presetFilters": { "followed": "Followed", @@ -457,6 +458,7 @@ "salutationName": "Salutation", "assignedUser": "Assigned User", "assignedUsers": "Assigned Users", + "collaborators": "Collaborators", "emailAddress": "Email", "emailAddressData": "Email Address Data", "emailAddressIsOptedOut": "Email Address is Opted-Out", @@ -493,6 +495,7 @@ "links": { "assignedUser": "Assigned User", "assignedUsers": "Assigned Users", + "collaborators": "Collaborators", "createdBy": "Created By", "modifiedBy": "Modified By", "team": "Team", diff --git a/application/Espo/Resources/metadata/app/aclPortal.json b/application/Espo/Resources/metadata/app/aclPortal.json index ce752b866e..99a01825e3 100644 --- a/application/Espo/Resources/metadata/app/aclPortal.json +++ b/application/Espo/Resources/metadata/app/aclPortal.json @@ -126,6 +126,7 @@ "read": "yes", "edit": "no" }, + "collaborators": false, "teams": false }, "scopeFieldLevel": { diff --git a/application/Espo/Resources/metadata/app/entityManager.json b/application/Espo/Resources/metadata/app/entityManager.json index 88772add4e..8258bda9b3 100644 --- a/application/Espo/Resources/metadata/app/entityManager.json +++ b/application/Espo/Resources/metadata/app/entityManager.json @@ -9,6 +9,7 @@ "Espo\\Tools\\EntityManager\\Hook\\Hooks\\DeleteHasChildrenLinks" ], "updateHookClassNameList": [ - "Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook" + "Espo\\Tools\\EntityManager\\Hook\\Hooks\\AssignedUsersUpdateHook", + "Espo\\Tools\\EntityManager\\Hook\\Hooks\\CollaboratorsUpdateHook" ] } diff --git a/application/Espo/Resources/metadata/app/entityManagerParams.json b/application/Espo/Resources/metadata/app/entityManagerParams.json index 8dd07d1b5f..cfc9fe714b 100644 --- a/application/Espo/Resources/metadata/app/entityManagerParams.json +++ b/application/Espo/Resources/metadata/app/entityManagerParams.json @@ -38,6 +38,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -62,6 +69,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -86,6 +100,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { @@ -110,6 +131,13 @@ "view": "views/admin/entity-manager/fields/duplicate-check-field-list" } }, + "collaborators": { + "location": "scopes", + "fieldDefs": { + "type": "bool", + "tooltip": true + } + }, "assignedUsers": { "location": "scopes", "fieldDefs": { diff --git a/application/Espo/Resources/metadata/app/relationships.json b/application/Espo/Resources/metadata/app/relationships.json index af3950aca5..e08b647478 100644 --- a/application/Espo/Resources/metadata/app/relationships.json +++ b/application/Espo/Resources/metadata/app/relationships.json @@ -11,6 +11,9 @@ "entityUser": { "converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\EntityUser" }, + "entityCollaborator": { + "converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\EntityCollaborator" + }, "smsPhoneNumber": { "converterClassName": "Espo\\Core\\Utils\\Database\\Orm\\LinkConverters\\SmsPhoneNumber" } diff --git a/application/Espo/Tools/EntityManager/Hook/Hooks/CollaboratorsUpdateHook.php b/application/Espo/Tools/EntityManager/Hook/Hooks/CollaboratorsUpdateHook.php new file mode 100644 index 0000000000..14120a0a6f --- /dev/null +++ b/application/Espo/Tools/EntityManager/Hook/Hooks/CollaboratorsUpdateHook.php @@ -0,0 +1,128 @@ +. + * + * 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\Tools\EntityManager\Hook\Hooks; + +use Espo\Core\ORM\Type\FieldType; +use Espo\Core\Utils\Log; +use Espo\Core\Utils\Metadata; +use Espo\Entities\User; +use Espo\ORM\Type\RelationType; +use Espo\Tools\EntityManager\Hook\UpdateHook; +use Espo\Tools\EntityManager\Params; + +/** + * @noinspection PhpUnused + */ +class CollaboratorsUpdateHook implements UpdateHook +{ + private const PARAM = 'collaborators'; + private const FIELD = 'collaborators'; + private const RELATION_NAME = 'entityCollaborator'; + + private const DEFAULT_MAX_COUNT = 30; + + public function __construct( + private Metadata $metadata, + private Log $log, + ) {} + + public function process(Params $params, Params $previousParams): void + { + if ($params->get(self::PARAM) && !$previousParams->get(self::PARAM)) { + $this->add($params->getName()); + } else if (!$params->get(self::PARAM) && $previousParams->get(self::PARAM)) { + $this->remove($params->getName()); + } + } + + private function add(string $entityType): void + { + if ($this->metadata->get("entityDefs.$entityType.links." . self::FIELD . ".isCustom")) { + $this->log->warning("Cannot enable collaborators for $entityType as the link already exists."); + + return; + } + + $this->metadata->set('entityDefs', $entityType, [ + 'fields' => [ + self::FIELD => [ + 'type' => FieldType::LINK_MULTIPLE, + 'view' => 'views/fields/collaborators', + 'maxCount' => self::DEFAULT_MAX_COUNT, + 'fieldManagerParamList' => [ + 'readOnly', + 'readOnlyAfterCreate', + 'audited', + 'autocompleteOnEmpty', + 'maxCount', + 'inlineEditDisabled', + 'tooltipText' + ] + ], + ], + 'links' => [ + self::FIELD => [ + 'type' => RelationType::HAS_MANY, + 'entity' => User::ENTITY_TYPE, + 'relationName' => self::RELATION_NAME, + 'layoutRelationshipsDisabled' => true, + ], + ], + ]); + + $this->metadata->set('entityAcl', $entityType, [ + 'links' => [ + self::FIELD => [ + 'readOnly' => true, + ], + ], + ]); + + $this->metadata->save(); + } + + private function remove(string $entityType): void + { + $field = self::FIELD; + + if ( + $this->metadata->get("entityDefs.$entityType.links.$field.isCustom") && + $this->metadata->get("entityDefs.$entityType.links.$field.relationName") !== self::RELATION_NAME + ) { + return; + } + + $this->metadata->delete('entityAcl', $entityType, [ + 'links.' . self::FIELD, + ]); + + $this->metadata->save(); + } +} diff --git a/application/Espo/Tools/EntityManager/NameUtil.php b/application/Espo/Tools/EntityManager/NameUtil.php index f7631b79de..4a7caa6c60 100644 --- a/application/Espo/Tools/EntityManager/NameUtil.php +++ b/application/Espo/Tools/EntityManager/NameUtil.php @@ -76,6 +76,7 @@ class NameUtil 'teams', 'assignedUser', 'assignedUsers', + 'collaborators', ]; /** diff --git a/application/Espo/Tools/FieldManager/FieldManager.php b/application/Espo/Tools/FieldManager/FieldManager.php index ce7123770b..0d3cbe6fa5 100644 --- a/application/Espo/Tools/FieldManager/FieldManager.php +++ b/application/Espo/Tools/FieldManager/FieldManager.php @@ -66,6 +66,7 @@ class FieldManager 'teams', 'assignedUser', 'assignedUsers', + 'collaborators', ]; /** @var string[] */ diff --git a/application/Espo/Tools/Stream/HookProcessor.php b/application/Espo/Tools/Stream/HookProcessor.php index 03ae284fb8..e6769d684e 100644 --- a/application/Espo/Tools/Stream/HookProcessor.php +++ b/application/Espo/Tools/Stream/HookProcessor.php @@ -524,7 +524,8 @@ class HookProcessor // @todo Introduce a metadata parameter. $entity->isAttributeChanged('assignedUserId') || $entity->isAttributeChanged('teamsIds') || - $entity->isAttributeChanged('assignedUsersIds') + $entity->isAttributeChanged('assignedUsersIds') || + $entity->isAttributeChanged('collaboratorsIds') ) ) ) { diff --git a/application/Espo/Tools/Stream/RecordService/Helper.php b/application/Espo/Tools/Stream/RecordService/Helper.php index 706e360061..9b98964e54 100644 --- a/application/Espo/Tools/Stream/RecordService/Helper.php +++ b/application/Espo/Tools/Stream/RecordService/Helper.php @@ -107,9 +107,7 @@ class Helper continue; } - if ( - $this->aclManager->checkReadOnlyOwn($user, $scope) - ) { + if ($this->aclManager->checkReadOnlyOwn($user, $scope)) { $list[] = $scope; } } diff --git a/application/Espo/Tools/Stream/Service.php b/application/Espo/Tools/Stream/Service.php index a5226e888d..a0f9d3070a 100644 --- a/application/Espo/Tools/Stream/Service.php +++ b/application/Espo/Tools/Stream/Service.php @@ -374,7 +374,7 @@ class Service * Notes having `related` or `superParent` are subjects to access control * through `users` and `teams` fields. * - * When users or teams of `related` or `parent` record are changed + * When users or teams of `related` or `parent` record are changed, * the note record will be changed too. */ private function processNoteTeamsUsers(Note $note, Entity $entity): void @@ -384,26 +384,37 @@ class Service } $note->setAclIsProcessed(); - $note->setTeamsIds([]); - $note->setUsersIds([]); if ($entity->hasLinkMultipleField('teams')) { - $teamIdList = $entity->getLinkMultipleIdList('teams'); - - $note->setTeamsIds($teamIdList); + $note->setTeamsIds($entity->getLinkMultipleIdList('teams')); } + $userIds = array_merge( + $this->getAssignedUserIds($entity), + $this->getCollaboratorIds($entity) + ); + + $userIds = array_values(array_unique($userIds)); + + $note->setUsersIds($userIds); + } + + /** + * @return string[] + */ + private function getAssignedUserIds(CoreEntity $entity): array + { $ownerUserField = $this->aclManager->getReadOwnerUserField($entity->getEntityType()); if (!$ownerUserField) { - return; + return []; } $defs = $this->entityManager->getDefs()->getEntity($entity->getEntityType()); if (!$defs->hasField($ownerUserField)) { - return; + return []; } $fieldDefs = $defs->getField($ownerUserField); @@ -413,26 +424,42 @@ class Service } else if ($fieldDefs->getType() === FieldType::LINK) { $ownerUserIdAttribute = $ownerUserField . 'Id'; } else { - return; + return []; } if (!$entity->has($ownerUserIdAttribute)) { - return; + return []; } if ($fieldDefs->getType() === FieldType::LINK_MULTIPLE) { - $userIdList = $entity->getLinkMultipleIdList($ownerUserField); - } else { - $userId = $entity->get($ownerUserIdAttribute); - - if (!$userId) { - return; - } - - $userIdList = [$userId]; + return $entity->getLinkMultipleIdList($ownerUserField); } - $note->setUsersIds($userIdList); + $userId = $entity->get($ownerUserIdAttribute); + + if ($userId) { + return [$userId]; + } + + return []; + } + + /** + * @return string[] + */ + private function getCollaboratorIds(CoreEntity $entity): array + { + if (!$this->metadata->get("scopes.{$entity->getEntityType()}.collaborators")) { + return []; + } + + $field = 'collaborators'; + + if (!$entity->hasLinkMultipleField($field)) { + return []; + } + + return $entity->getLinkMultipleIdList($field); } public function noteEmailReceived(Entity $entity, Email $email, bool $isInitial = false): void diff --git a/client/src/acl-manager.js b/client/src/acl-manager.js index 9d6d22d6da..53c04497ed 100644 --- a/client/src/acl-manager.js +++ b/client/src/acl-manager.js @@ -157,7 +157,7 @@ class AclManager { * * @param {string} scope A scope. * @param {module:acl-manager~action} action An action. - * @returns {'yes'|'all'|'team'|'no'|null} + * @returns {'yes'|'all'|'team'|'own'|'no'|null} */ getLevel(scope, action) { if (!(scope in this.data.table)) { @@ -301,6 +301,17 @@ class AclManager { return this.getImplementation(model.entityType).checkInTeam(model); } + // noinspection JSUnusedGlobalSymbols + /** + * Check if a record is shared with the user. + * + * @param {module:model} model A model. + * @returns {boolean|null} True if shared, null if not clear. + */ + checkIsShared(model) { + return this.getImplementation(model.entityType).checkIsShared(model); + } + // noinspection JSUnusedGlobalSymbols /** * Check an assignment permission to a user. diff --git a/client/src/acl.js b/client/src/acl.js index 3e22b1aa65..1e1e2a1e5f 100644 --- a/client/src/acl.js +++ b/client/src/acl.js @@ -56,7 +56,17 @@ class Acl { this.aclAllowDeleteCreated = params.aclAllowDeleteCreated; this.teamsFieldIsForbidden = params.teamsFieldIsForbidden; - this.forbiddenFieldList = params.forbiddenFieldList; + + /** + * @type {string[]} + */ + this.forbiddenFieldList = params.forbiddenFieldList || []; + + /** + * @protected + * @type {boolean} + */ + this.collaboratorsFieldIsForbidden = this.forbiddenFieldList.includes('collaborators'); /** * @type {import('acl-manager').default} @@ -81,7 +91,7 @@ class Acl { * @param {string|boolean|Object.} data Access data. * @param {module:acl-manager~action|null} [action=null] An action. * @param {boolean} [precise=false] To return `null` if `inTeam == null`. - * @param {Object|null} [entityAccessData=null] Entity access data. `inTeam`, `isOwner`. + * @param {Record.|null} [entityAccessData=null] Entity access data. `inTeam`, `isOwner`. * @returns {boolean|null} True if access allowed. */ checkScope(data, action, precise, entityAccessData) { @@ -89,6 +99,7 @@ class Acl { const inTeam = entityAccessData.inTeam; const isOwner = entityAccessData.isOwner; + const isShared = entityAccessData.isShared; if (this.getUser().isAdmin()) { if (data === false) { @@ -138,7 +149,7 @@ class Acl { return false; } - if (typeof isOwner === 'undefined') { + if (isOwner === undefined) { return true; } @@ -148,31 +159,32 @@ class Acl { } } - let result = false; + if (isShared) { + return true; + } - if (value === 'team') { - result = inTeam; - - if (inTeam === null) { - if (precise) { - result = null; - } else { - return true; - } - } - else if (inTeam) { + if (inTeam) { + if (value === 'team') { return true; } } - if (isOwner === null) { - if (precise) { + let result = false; + + if (value === 'team') { + if (inTeam === null && precise) { result = null; - } else { - return true; } } + if (isOwner === null && precise) { + result = null; + } + + if (isShared === null) { + result = null; + } + return result; } @@ -191,9 +203,16 @@ class Acl { return true; } + let isShared = false; + + if (action === 'read' || action === 'stream') { + isShared = this.checkIsShared(model); + } + const entityAccessData = { isOwner: this.checkIsOwner(model), inTeam: this.checkInTeam(model), + isShared: isShared, }; return this.checkScope(data, action, precise, entityAccessData); @@ -304,6 +323,10 @@ class Acl { return true; } + if (!model.hasField('teams')) { + return false; + } + return null; } @@ -320,6 +343,30 @@ class Acl { return inTeam; } + /** + * Check if a record is shared with the user. + * + * @param {module:model} model A model. + * @returns {boolean|null} True if shared. Null if not enough data to determine. + */ + checkIsShared(model) { + if (!model.has('collaboratorsIds')) { + if (this.collaboratorsFieldIsForbidden) { + return true; + } + + if (!model.hasField('collaborators')) { + return false; + } + + return null; + } + + const collaboratorsIds = model.getLinkMultipleIdList('collaborators'); + + return collaboratorsIds.includes(this.user.id); + } + /** * Get a permission level. * diff --git a/client/src/helpers/model/defaults-populator.js b/client/src/helpers/model/defaults-populator.js index 2859523daf..3e3da90d8a 100644 --- a/client/src/helpers/model/defaults-populator.js +++ b/client/src/helpers/model/defaults-populator.js @@ -137,6 +137,15 @@ class DefaultsPopulator { defaultHash['teamsNames'][defaultTeamId] = this.user.get('defaultTeamName'); } } + + const hasCollaborators = model.hasField('collaborators') && + model.getLinkParam('collaborators', 'entity') === 'User' && + this.metadata.get(`scopes.${model.entityType}.collaborators`); + + if (hasCollaborators) { + defaultHash.collaboratorsIds = [this.user.id]; + defaultHash.collaboratorsNames = {[this.user.id]: this.user.attributes.name}; + } } /** diff --git a/client/src/views/admin/field-manager/edit.js b/client/src/views/admin/field-manager/edit.js index 45ca160caf..9a4d1cab68 100644 --- a/client/src/views/admin/field-manager/edit.js +++ b/client/src/views/admin/field-manager/edit.js @@ -250,7 +250,7 @@ class FieldManagerEditView extends View { if ( item === 'createButton' && - ['assignedUser', 'assignedUsers', 'teams'].includes(this.field) + ['assignedUser', 'assignedUsers', 'teams', 'collaborators'].includes(this.field) ) { return; } diff --git a/client/src/views/dashlets/fields/records/bool-filter-list.js b/client/src/views/dashlets/fields/records/bool-filter-list.js index 430196f5cd..9974c413bc 100644 --- a/client/src/views/dashlets/fields/records/bool-filter-list.js +++ b/client/src/views/dashlets/fields/records/bool-filter-list.js @@ -76,6 +76,10 @@ export default class extends MultiEnumFieldView { this.params.options.push('followed'); } + if (this.getMetadata().get(`scopes.${entityType}.collaborators`)) { + this.params.options.push('shared'); + } + this.translatedOptions = {}; this.params.options.forEach(item => { diff --git a/client/src/views/fields/collaborators.js b/client/src/views/fields/collaborators.js new file mode 100644 index 0000000000..33fda7191a --- /dev/null +++ b/client/src/views/fields/collaborators.js @@ -0,0 +1,78 @@ +/************************************************************************ + * This file is part of EspoCRM. + * + * EspoCRM – Open Source CRM application. + * Copyright (C) 2014-2024 Yurii Kuznietsov, Taras Machyshyn, Oleksii Avramenko + * Website: https://www.espocrm.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * 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. + ************************************************************************/ + +import LinkMultipleFieldView from 'views/fields/link-multiple'; + +export default class CollaboratorsFieldView extends LinkMultipleFieldView { + + init() { + this.assignmentPermission = this.getAcl().getPermissionLevel('assignmentPermission'); + + if (this.assignmentPermission === 'no') { + this.readOnly = true; + } + + super.init(); + } + + getSelectBoolFilterList() { + if (this.assignmentPermission === 'team') { + return ['onlyMyTeam']; + } + } + + getSelectPrimaryFilterName() { + return 'active'; + } + + getDetailLinkHtml(id, name) { + const html = super.getDetailLinkHtml(id); + + const avatarHtml = this.isDetailMode() ? + this.getHelper().getAvatarHtml(id, 'small', 18, 'avatar-link') : ''; + + if (!avatarHtml) { + return html; + } + + return `${avatarHtml} ${html}`; + } + + /** @inheritDoc */ + getOnEmptyAutocomplete() { + if (this.params.autocompleteOnEmpty) { + return undefined; + } + + if (this.ids && this.ids.includes(this.getUser().id)) { + return Promise.resolve([]); + } + + return Promise.resolve([]); + } +} diff --git a/client/src/views/record/search.js b/client/src/views/record/search.js index 53fac9f94c..bff1974028 100644 --- a/client/src/views/record/search.js +++ b/client/src/views/record/search.js @@ -206,6 +206,10 @@ class SearchView extends View { this.boolFilterList.push('followed'); } + if (this.getMetadata().get(`scopes.${this.entityType}.collaborators`) && !this.getUser().isPortal()) { + this.boolFilterList.push('shared'); + } + this.loadSearchData(); if (this.hasAdvancedFilter()) { diff --git a/client/src/views/role/record/table.js b/client/src/views/role/record/table.js index 0b1077784c..43a4cd3b3c 100644 --- a/client/src/views/role/record/table.js +++ b/client/src/views/role/record/table.js @@ -39,6 +39,7 @@ class RoleRecordTableView extends View { /** @type {'detail'|'string'} */ mode = 'detail' lowestLevelByDefault = true + collaborators = true actionList = ['create', 'read', 'edit', 'delete', 'stream'] accessList = ['not-set', 'enabled', 'disabled'] @@ -211,6 +212,14 @@ class RoleRecordTableView extends View { } } + if (level && !levelList.includes(level)) { + levelList.push(level); + + levelList.sort((a, b) => { + return this.levelList.findIndex(it => it === a) - this.levelList.findIndex(it => it === b); + }); + } + list.push({ level: level, name: scope + '-' + action, @@ -231,17 +240,28 @@ class RoleRecordTableView extends View { return aclDataList; } + /** + * @private + * @param {string} scope + * @param {'create'|'read'|'edit'|'delete'|'stream'} action + * @return {string[]} + */ getLevelList(scope, action) { if (this.booleanActionList.includes(action)) { return this.booleanLevelList; } + const specifiedLevelList = + this.getMetadata().get(`scopes.${scope}.${this.type}ActionLevelListMap.${action}`) || + this.getMetadata().get(`scopes.${scope}.${this.type}LevelList`); + + if (specifiedLevelList) { + return specifiedLevelList; + } + const type = this.aclTypeMap[scope]; - return this.getMetadata().get(['scopes', scope, this.type + 'ActionLevelListMap', action]) || - this.getMetadata().get(['scopes', scope, this.type + 'LevelList']) || - this.levelListMap[type] || - []; + return this.levelListMap[type] || []; } setup() { diff --git a/schema/metadata/app/acl.json b/schema/metadata/app/acl.json index f9fedbffc7..1b2da9de51 100644 --- a/schema/metadata/app/acl.json +++ b/schema/metadata/app/acl.json @@ -47,10 +47,10 @@ "enum": ["yes", "no"] }, "levelsAll": { - "enum": ["yes", "all", "team", "own", "no"] + "enum": ["yes", "all", "team", "shared", "own", "no"] }, "levels": { - "enum": ["all", "team", "own", "no"] + "enum": ["all", "team", "shared", "own", "no"] }, "group": { "type": "object", diff --git a/schema/metadata/scopes.json b/schema/metadata/scopes.json index c2e7125c67..5e1a2ab1a3 100644 --- a/schema/metadata/scopes.json +++ b/schema/metadata/scopes.json @@ -25,6 +25,10 @@ "description": "Enables stars.", "type": "boolean" }, + "collaborators": { + "description": "Enables collaborators.", + "type": "boolean" + }, "importable": { "description": "Whether the entity is available in the Import tool.", "type": "boolean" diff --git a/tests/integration/Espo/User/AclTest.php b/tests/integration/Espo/User/AclTest.php index 66be822390..03fb90be4f 100644 --- a/tests/integration/Espo/User/AclTest.php +++ b/tests/integration/Espo/User/AclTest.php @@ -29,17 +29,22 @@ namespace tests\integration\Espo\User; +use Espo\Core\Acl\Table; +use Espo\Core\AclManager; use Espo\Core\Api\ControllerActionProcessor; use Espo\Core\Api\ResponseWrapper; +use Espo\Core\DataManager; use Espo\Core\Field\Date; use Espo\Core\Record\CreateParams; use Espo\Core\Record\ServiceContainer; use Espo\Core\Record\UpdateParams; use Espo\Core\Select\SearchParams; +use Espo\Core\Select\SelectBuilderFactory; use Espo\Core\Select\Where\Item as WhereItem; use Espo\Core\Exceptions\Forbidden; +use Espo\Entities\Team; use Espo\Entities\User; use Espo\Modules\Crm\Entities\Account; use Espo\Modules\Crm\Entities\CaseObj; @@ -47,6 +52,8 @@ use Espo\Modules\Crm\Entities\Contact; use Espo\Modules\Crm\Entities\Lead; use Espo\Modules\Crm\Entities\Meeting; use Espo\Modules\Crm\Entities\Opportunity; +use Espo\Modules\Crm\Entities\Task; +use Espo\Tools\EntityManager\EntityManager as EntityManagerTool; use Exception; class AclTest extends \tests\integration\Core\BaseTestCase @@ -705,4 +712,156 @@ class AclTest extends \tests\integration\Core\BaseTestCase 'assignedUserId' => $userId, ], CreateParams::create()); } + + /** @noinspection PhpUnhandledExceptionInspection */ + public function testShared(): void + { + $entityManagerTool = $this->getInjectableFactory()->create(EntityManagerTool::class); + $dataManager = $this->getContainer()->getByClass(DataManager::class); + + /** @noinspection PhpArrayKeyDoesNotMatchArrayShapeInspection */ + $entityManagerTool->update(Task::ENTITY_TYPE, ['collaborators' => true]); + $dataManager->rebuild(); + + $user1 = $this->createUser('test-1', [ + 'data' => [ + Task::ENTITY_TYPE => [ + Table::ACTION_READ => Table::LEVEL_OWN, + Table::ACTION_EDIT => Table::LEVEL_OWN, + Table::ACTION_STREAM => Table::LEVEL_OWN, + ] + ] + ]); + + $user2 = $this->createUser('test-2', [ + 'data' => [ + Task::ENTITY_TYPE => [ + Table::ACTION_READ => Table::LEVEL_OWN, + Table::ACTION_EDIT => Table::LEVEL_OWN, + Table::ACTION_STREAM => Table::LEVEL_NO, + ] + ] + ]); + + $user3 = $this->createUser('test-3', [ + 'data' => [ + Task::ENTITY_TYPE => [ + Table::ACTION_READ => Table::LEVEL_TEAM, + Table::ACTION_EDIT => Table::LEVEL_TEAM, + Table::ACTION_STREAM => Table::LEVEL_TEAM, + ] + ] + ]); + + $user4 = $this->createUser('test-4', [ + 'data' => [ + Task::ENTITY_TYPE => [ + Table::ACTION_READ => Table::LEVEL_NO, + Table::ACTION_EDIT => Table::LEVEL_NO, + Table::ACTION_STREAM => Table::LEVEL_NO, + ] + ] + ]); + + $em = $this->getEntityManager(); + $aclManager = $this->getContainer()->getByClass(AclManager::class); + + $team = $em->createEntity(Team::ENTITY_TYPE); + + $em->getRelation($user3, 'teams')->relate($team); + + $em->refreshEntity($user1); + $em->refreshEntity($user2); + $em->refreshEntity($user3); + $em->refreshEntity($user4); + + $entity1 = $em->createEntity(Task::ENTITY_TYPE, [ + 'collaboratorsIds' => [$user1->getId(), $user3->getId()] + ]); + + $entity2 = $em->createEntity(Task::ENTITY_TYPE, [ + 'teamsIds' => [$team->getId()] + ]); + + $entity3 = $em->createEntity(Task::ENTITY_TYPE, [ + 'collaboratorsIds' => [$user3->getId()] + ]); + + $entity4 = $em->createEntity(Task::ENTITY_TYPE, [ + 'collaboratorsIds' => [$user2->getId(), $user4->getId()] + ]); + + $this->assertTrue($aclManager->checkEntityRead($user1, $entity1)); + $this->assertFalse($aclManager->checkEntityEdit($user1, $entity1)); + $this->assertTrue($aclManager->checkEntityStream($user1, $entity1)); + + $this->assertFalse($aclManager->checkEntityRead($user2, $entity1)); + $this->assertFalse($aclManager->checkEntityEdit($user2, $entity1)); + $this->assertFalse($aclManager->checkEntityStream($user2, $entity1)); + + $this->assertTrue($aclManager->checkEntityRead($user3, $entity1)); + $this->assertFalse($aclManager->checkEntityEdit($user3, $entity1)); + $this->assertTrue($aclManager->checkEntityStream($user3, $entity1)); + + $this->assertTrue($aclManager->checkEntityRead($user2, $entity4)); + $this->assertFalse($aclManager->checkEntityStream($user2, $entity4)); + + $this->assertFalse($aclManager->checkEntityRead($user4, $entity4)); + $this->assertFalse($aclManager->checkEntityStream($user4, $entity4)); + + $selectBuilderFactory = $this->getInjectableFactory()->create(SelectBuilderFactory::class); + + // user1 + + $query = $selectBuilderFactory + ->create() + ->forUser($user1) + ->from(Task::ENTITY_TYPE) + ->withAccessControlFilter() + ->build(); + + $entity1Found = $em->getRDBRepositoryByClass(Task::class) + ->clone($query) + ->where(['id' => $entity1->getId()]) + ->findOne(); + + $this->assertNotNull($entity1Found); + + $entity2Found = $em->getRDBRepositoryByClass(Task::class) + ->clone($query) + ->where(['id' => $entity2->getId()]) + ->findOne(); + + $this->assertNull($entity2Found); + + // user3 + + $query = $selectBuilderFactory + ->create() + ->forUser($user3) + ->from(Task::ENTITY_TYPE) + ->withAccessControlFilter() + ->build(); + + $entity1Found = $em->getRDBRepositoryByClass(Task::class) + ->clone($query) + ->where(['id' => $entity1->getId()]) + ->findOne(); + + $this->assertNotNull($entity1Found); + + $entity2Found = $em->getRDBRepositoryByClass(Task::class) + ->clone($query) + ->where(['id' => $entity2->getId()]) + ->findOne(); + + $this->assertNotNull($entity2Found); + + $entity3Found = $em->getRDBRepositoryByClass(Task::class) + ->clone($query) + ->where(['id' => $entity3->getId()]) + ->findOne(); + + $this->assertNotNull($entity3Found); + } } diff --git a/tests/unit/Espo/Core/Acl/ScopeCheckerDataTest.php b/tests/unit/Espo/Core/Acl/ScopeCheckerDataTest.php index 4f68dfdb9a..82a811cfc4 100644 --- a/tests/unit/Espo/Core/Acl/ScopeCheckerDataTest.php +++ b/tests/unit/Espo/Core/Acl/ScopeCheckerDataTest.php @@ -29,11 +29,10 @@ namespace tests\unit\Espo\Core\Acl; -use Espo\Core\{ - Acl\AccessChecker\ScopeCheckerData, -}; +use Espo\Core\Acl\AccessChecker\ScopeCheckerData; +use PHPUnit\Framework\TestCase; -class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase +class ScopeCheckerDataTest extends TestCase { protected function setUp() : void { @@ -45,8 +44,9 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase ::createBuilder() ->build(); - $this->assertEquals(false, $checkerData->isOwn()); - $this->assertEquals(false, $checkerData->inTeam()); + $this->assertFalse($checkerData->isOwn()); + $this->assertFalse($checkerData->inTeam()); + $this->assertFalse($checkerData->isShared()); } public function testCheckerData1() @@ -55,10 +55,12 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase ::createBuilder() ->setIsOwn(true) ->setInTeam(true) + ->setIsShared(true) ->build(); - $this->assertEquals(true, $checkerData->isOwn()); - $this->assertEquals(true, $checkerData->inTeam()); + $this->assertTrue($checkerData->isOwn()); + $this->assertTrue($checkerData->inTeam()); + $this->assertTrue($checkerData->isShared()); } public function testCheckerData2() @@ -71,10 +73,16 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase return true; } ) + ->setIsSharedChecker( + function (): bool { + return true; + } + ) ->build(); - $this->assertEquals(false, $checkerData->isOwn()); - $this->assertEquals(true, $checkerData->inTeam()); + $this->assertFalse($checkerData->isOwn()); + $this->assertTrue($checkerData->inTeam()); + $this->assertTrue($checkerData->isShared()); } public function testCheckerData3() @@ -91,9 +99,15 @@ class ScopeCheckerDataTest extends \PHPUnit\Framework\TestCase return false; } ) + ->setIsSharedChecker( + function (): bool { + return false; + } + ) ->build(); - $this->assertEquals(true, $checkerData->isOwn()); - $this->assertEquals(false, $checkerData->inTeam()); + $this->assertTrue($checkerData->isOwn()); + $this->assertFalse($checkerData->inTeam()); + $this->assertFalse($checkerData->isShared()); } }