diff --git a/application/Espo/Controllers/User.php b/application/Espo/Controllers/User.php index bd7e7ccee6..6963f1786e 100644 --- a/application/Espo/Controllers/User.php +++ b/application/Espo/Controllers/User.php @@ -54,7 +54,7 @@ class User extends \Espo\Core\Controllers\Record throw new NotFound(); } - return $this->getAclManager()->getMap($user); + return $this->getAclManager()->getMapData($user); } public function postActionChangeOwnPassword($params, $data, $request) diff --git a/application/Espo/Core/Acl.php b/application/Espo/Core/Acl.php index cce0b23d83..918a44d2cc 100644 --- a/application/Espo/Core/Acl.php +++ b/application/Espo/Core/Acl.php @@ -57,9 +57,9 @@ class Acl /** * Get a full access data map. */ - public function getMap(): StdClass + public function getMapData(): StdClass { - return $this->aclManager->getMap($this->user); + return $this->aclManager->getMapData($this->user); } /** diff --git a/application/Espo/Core/Acl/DefaultTable.php b/application/Espo/Core/Acl/DefaultTable.php index 8f638bb14e..e06978e8c8 100644 --- a/application/Espo/Core/Acl/DefaultTable.php +++ b/application/Espo/Core/Acl/DefaultTable.php @@ -29,16 +29,14 @@ namespace Espo\Core\Acl; - use Espo\Entities\User; use Espo\Core\{ - ORM\EntityManager, + Acl\Table\RoleListProvider, + Acl\Table\CacheKeyProvider, Utils\Config, Utils\Metadata, - Utils\FieldUtil, Utils\DataCache, - Utils\ObjectUtil, }; use StdClass; @@ -50,17 +48,13 @@ use RuntimeException; */ class DefaultTable implements Table { - protected const LEVEL_NOT_SET = 'not-set'; + private const LEVEL_NOT_SET = 'not-set'; protected $type = 'acl'; protected $defaultAclType = 'recordAllTeamOwnNo'; - private $data = null; - - protected $cacheKey; - - protected $actionList = [ + private $actionList = [ self::ACTION_READ, self::ACTION_STREAM, self::ACTION_EDIT, @@ -68,7 +62,7 @@ class DefaultTable implements Table self::ACTION_CREATE, ]; - protected $booleanActionList = [ + private $booleanActionList = [ self::ACTION_CREATE, ]; @@ -80,7 +74,7 @@ class DefaultTable implements Table self::LEVEL_NO, ]; - protected $fieldActionList = [ + private $fieldActionList = [ self::ACTION_READ, self::ACTION_EDIT, ]; @@ -90,44 +84,36 @@ class DefaultTable implements Table self::LEVEL_NO, ]; - protected $valuePermissionHighestLevels = []; - - protected $valuePermissionList = []; - - protected $forbiddenAttributesCache = []; - - protected $forbiddenFieldsCache = []; - protected $isStrictModeForced = false; - protected $isStrictMode = false; + private $isStrictMode = false; - protected $entityManager; + private $data = null; + + private $cacheKey; + + private $valuePermissionList = []; + + private $roleListProvider; protected $user; - protected $config; - protected $metadata; - protected $fieldUtil; - - protected $dataCache; - public function __construct( - EntityManager $entityManager, + RoleListProvider $roleListProvider, + CacheKeyProvider $cacheKeyProvider, User $user, Config $config, Metadata $metadata, - FieldUtil $fieldUtil, DataCache $dataCache ) { - $this->entityManager = $entityManager; + $this->roleListProvider = $roleListProvider; $this->data = (object) [ - 'table' => (object) [], - 'fieldTable' => (object) [], - 'fieldTableQuickAccess' => (object) [], + 'scopes' => (object) [], + 'fields' => (object) [], + 'permissions' => (object) [], ]; if ($this->isStrictModeForced) { @@ -139,8 +125,6 @@ class DefaultTable implements Table $this->user = $user; $this->metadata = $metadata; - $this->fieldUtil = $fieldUtil; - $this->dataCache = $dataCache; if (!$this->user->isFetched()) { throw new RuntimeException('User must be fetched before ACL check.'); @@ -149,46 +133,30 @@ class DefaultTable implements Table $this->valuePermissionList = $this->metadata ->get(['app', $this->type, 'valuePermissionList'], []); - $this->valuePermissionHighestLevels = $this->metadata - ->get(['app', $this->type, 'valuePermissionHighestLevels'], []); + $this->cacheKey = $cacheKeyProvider->get(); - $this->initCacheKey(); - - if ($config && $config->get('useCache') && $this->dataCache->has($this->cacheKey)) { - $this->data = $this->dataCache->get($this->cacheKey); + if ($config->get('useCache') && $dataCache->has($this->cacheKey)) { + $this->data = $dataCache->get($this->cacheKey); } else { $this->load(); - if ($config && $config->get('useCache')) { - $this->buildCache(); + if ($config->get('useCache')) { + $dataCache->store($this->cacheKey, $this->data); } } } - protected function initCacheKey(): void - { - $this->cacheKey = 'acl/' . $this->user->id; - } - - /** - * Get a full map. - */ - public function getMap(): StdClass - { - return ObjectUtil::clone($this->data); - } - /** * Get scope data. */ public function getScopeData(string $scope): ScopeData { - if (!isset($this->data->table->$scope)) { + if (!isset($this->data->scopes->$scope)) { return ScopeData::fromRaw(false); } - $data = $this->data->table->$scope; + $data = $this->data->scopes->$scope; if (is_string($data)) { return $this->getScopeData($data); @@ -197,14 +165,32 @@ class DefaultTable implements Table return ScopeData::fromRaw($data); } + /** + * Get field data. + */ + public function getFieldData(string $scope, string $field): FieldData + { + if (!isset($this->data->fields->$scope)) { + return FieldData::fromRaw((object) [ + self::ACTION_READ => self::LEVEL_YES, + self::ACTION_EDIT => self::LEVEL_YES, + ]); + } + + $data = $this->data->fields->$scope->$field ?? (object) [ + self::ACTION_READ => self::LEVEL_YES, + self::ACTION_EDIT => self::LEVEL_YES, + ]; + + return FieldData::fromRaw($data); + } + /** * Get a permission level. */ public function getPermissionLevel(string $permission): string { - $key = $permission . 'Permission'; - - return $this->data->$key ?? self::LEVEL_NO; + return $this->data->permissions->$permission ?? self::LEVEL_NO; } private function load(): void @@ -219,14 +205,16 @@ class DefaultTable implements Table $fieldTableList = []; if (!$this->user->isAdmin()) { - $roleList = $this->getRoleList(); + $roleList = $this->roleListProvider->get(); foreach ($roleList as $role) { - $aclTableList[] = $role->get('data'); - $fieldTableList[] = $role->get('fieldData'); + $aclTableList[] = $role->getScopeTableData(); + $fieldTableList[] = $role->getFieldTableData(); - foreach ($this->valuePermissionList as $permission) { - $valuePermissionLists->{$permission}[] = $role->get($permission); + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normilizePermissionName($permissionKey); + + $valuePermissionLists->{$permissionKey}[] = $role->getPermissionLevel($permission); } } @@ -252,10 +240,8 @@ class DefaultTable implements Table } } - $this->data->table = $aclTable; - $this->data->fieldTable = $fieldTable; - - $this->fillFieldTableQuickAccess(); + $this->data->scopes = $aclTable; + $this->data->fields = $fieldTable; if (!$this->user->isAdmin()) { $permissionsDefaultsGroupName = 'permissionsDefaults'; @@ -264,221 +250,53 @@ class DefaultTable implements Table $permissionsDefaultsGroupName = 'permissionsStrictDefaults'; } - foreach ($this->valuePermissionList as $permission) { - $this->data->$permission = $this->mergeValueList( - $valuePermissionLists->$permission, - $this->metadata - ->get(['app', $this->type, $permissionsDefaultsGroupName, $permission, self::LEVEL_YES]) + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normilizePermissionName($permissionKey); + + $defaultLevel = $this->metadata + ->get(['app', $this->type, $permissionsDefaultsGroupName, $permissionKey]) ?? + ($this->isStrictMode ? self::LEVEL_NO : self::LEVEL_YES); + + $this->data->permissions->$permission = $this->mergeValueList( + $valuePermissionLists->$permissionKey, + $defaultLevel ); - if ($this->metadata->get('app.'.$this->type.'.mandatory.' . $permission)) { - $this->data->$permission = $this->metadata - ->get('app.'.$this->type.'.mandatory.' . $permission); + $mandatoryLevel = $this->metadata->get(['app', $this->type, 'mandatory', $permissionKey]); + + if ($mandatoryLevel !== null) { + $this->data->permissions->$permission = $mandatoryLevel; } } } if ($this->user->isAdmin()) { - foreach ($this->valuePermissionList as $permission) { - if (isset($this->valuePermissionHighestLevels[$permission])) { - $this->data->$permission = $this->valuePermissionHighestLevels[$permission]; + foreach ($this->valuePermissionList as $permissionKey) { + $permission = $this->normilizePermissionName($permissionKey); + + $highestLevel = $this->metadata + ->get(['app', $this->type, 'valuePermissionHighestLevels', $permissionKey]); + + if ($highestLevel !== null) { + $this->data->permissions->$permission = $highestLevel; continue; } - $this->data->$permission = self::LEVEL_ALL; + $this->data->permissions->$permission = self::LEVEL_ALL; } } } - protected function getRoleList(): array + private function normilizePermissionName(string $permissionKey): string { - $roleList = []; + $permission = $permissionKey; - $userRoleList = $this->entityManager - ->getRepository('User') - ->getRelation($this->user, 'roles') - ->find(); - - foreach ($userRoleList as $role) { - $roleList[] = $role; + if (substr($permissionKey, -10) === 'Permission') { + $permission = substr($permissionKey, 0, -10); } - $teamList = $this->entityManager - ->getRepository('User') - ->getRelation($this->user, 'teams') - ->find(); - - foreach ($teamList as $team) { - $teamRoleList = $this->entityManager - ->getRepository('Team') - ->getRelation($team, 'roles') - ->find(); - - foreach ($teamRoleList as $role) { - $roleList[] = $role; - } - } - - return $roleList; - } - - public function getScopeForbiddenAttributeList( - string $scope, - string $action = self::ACTION_READ, - string $thresholdLevel = self::LEVEL_NO - ): array { - - if (!in_array($thresholdLevel, $this->fieldLevelList) || $thresholdLevel === self::LEVEL_YES) { - throw new RuntimeException("Bad threshold level."); - } - - $key = $scope . '_'. $action . '_' . $thresholdLevel; - - if (isset($this->forbiddenAttributesCache[$key])) { - return $this->forbiddenAttributesCache[$key]; - } - - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; - - if ( - !isset($fieldTableQuickAccess->$scope) || !isset($fieldTableQuickAccess->$scope->attributes) || - !isset($fieldTableQuickAccess->$scope->attributes->$action) - ) { - $this->forbiddenAttributesCache[$key] = []; - - return []; - } - - $levelList = []; - - foreach ($this->fieldLevelList as $level) { - if (array_search($level, $this->fieldLevelList) >= array_search($thresholdLevel, $this->fieldLevelList)) { - $levelList[] = $level; - } - } - - $attributeList = []; - - foreach ($levelList as $level) { - if (!isset($fieldTableQuickAccess->$scope->attributes->$action->$level)) { - continue; - } - - foreach ($fieldTableQuickAccess->$scope->attributes->$action->$level as $attribute) { - if (in_array($attribute, $attributeList)) { - continue; - } - - $attributeList[] = $attribute; - } - } - - $this->forbiddenAttributesCache[$key] = $attributeList; - - return $attributeList; - } - - public function getScopeForbiddenFieldList( - string $scope, - string $action = self::ACTION_READ, - string $thresholdLevel = self::LEVEL_NO - ): array { - - if (!in_array($thresholdLevel, $this->fieldLevelList) || $thresholdLevel === self::LEVEL_YES) { - throw new RuntimeException("Bad threshold level."); - } - - $key = $scope . '_'. $action . '_' . $thresholdLevel; - - if (isset($this->forbiddenFieldsCache[$key])) { - return $this->forbiddenFieldsCache[$key]; - } - - $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; - - if ( - !isset($fieldTableQuickAccess->$scope) || !isset($fieldTableQuickAccess->$scope->fields) || - !isset($fieldTableQuickAccess->$scope->fields->$action) - ) { - $this->forbiddenFieldsCache[$key] = []; - - return []; - } - - $levelList = []; - - foreach ($this->fieldLevelList as $level) { - if (array_search($level, $this->fieldLevelList) >= array_search($thresholdLevel, $this->fieldLevelList)) { - $levelList[] = $level; - } - } - - $fieldList = []; - - foreach ($levelList as $level) { - if (!isset($fieldTableQuickAccess->$scope->fields->$action->$level)) { - continue; - } - - foreach ($fieldTableQuickAccess->$scope->fields->$action->$level as $field) { - if (in_array($field, $fieldList)) { - continue; - } - - $fieldList[] = $field; - } - } - - $this->forbiddenFieldsCache[$key] = $fieldList; - - return $fieldList; - } - - protected function fillFieldTableQuickAccess(): void - { - $fieldTable = $this->data->fieldTable; - - $fieldTableQuickAccess = (object) []; - - foreach (get_object_vars($fieldTable) as $scope => $scopeData) { - $fieldTableQuickAccess->$scope = (object) [ - 'attributes' => (object) [], - 'fields' => (object) [] - ]; - - foreach ($this->fieldActionList as $action) { - $fieldTableQuickAccess->$scope->attributes->$action = (object) []; - $fieldTableQuickAccess->$scope->fields->$action = (object) []; - - foreach ($this->fieldLevelList as $level) { - $fieldTableQuickAccess->$scope->attributes->$action->$level = []; - $fieldTableQuickAccess->$scope->fields->$action->$level = []; - } - } - - foreach (get_object_vars($scopeData) as $field => $fieldData) { - $attributeList = $this->fieldUtil->getAttributeList($scope, $field); - - foreach ($this->fieldActionList as $action) { - if (!isset($fieldData->$action)) { - continue; - } - - foreach ($this->fieldLevelList as $level) { - if ($fieldData->$action === $level) { - $fieldTableQuickAccess->$scope->fields->$action->{$level}[] = $field; - - foreach ($attributeList as $attribute) { - $fieldTableQuickAccess->$scope->attributes->$action->{$level}[] = $attribute; - } - } - } - } - } - } - - $this->data->fieldTableQuickAccess = $fieldTableQuickAccess; + return $permission; } protected function applyHighest(StdClass &$table, StdClass &$fieldTable): void @@ -544,21 +362,22 @@ class DefaultTable implements Table $table->$scope = $value; } - $defaultFieldData = $this->metadata->get(['app', $this->type, $defaultsGroupName, 'fieldLevel'], []); + $defaultFieldData = $this->metadata + ->get(['app', $this->type, $defaultsGroupName, 'fieldLevel']) ?? []; foreach ($this->getScopeList() as $scope) { if (isset($table->$scope) && $table->$scope === false) { continue; } - if (!$this->metadata->get('scopes.' . $scope . '.entity')) { + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { continue; } - $fieldList = array_keys($this->metadata->get("entityDefs.{$scope}.fields", [])); + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); $defaultScopeFieldData = $this->metadata - ->get('app.'.$this->type.'.'.$defaultsGroupName.'.scopeFieldLevel.' . $scope, []); + ->get(['app', $this->type, $defaultsGroupName, 'scopeFieldLevel', $scope]) ?? []; foreach (array_merge($defaultFieldData, $defaultScopeFieldData) as $field => $f) { if (!in_array($field, $fieldList)) { @@ -593,41 +412,43 @@ class DefaultTable implements Table } foreach ($this->getScopeWithAclList() as $scope) { - if (!isset($table->$scope)) { - $aclType = $this->metadata->get('scopes.' . $scope . '.' . $this->type); + if (isset($table->$scope)) { + continue; + } - if ($aclType === true) { - $aclType = $this->defaultAclType; - } + $aclType = $this->metadata->get(['scopes', $scope, $this->type]); - if (!empty($aclType)) { - $paramDefaultsName = 'scopeLevelTypesDefaults'; + if ($aclType === true) { + $aclType = $this->defaultAclType; + } - if ($this->isStrictMode) { - $paramDefaultsName = 'scopeLevelTypesStrictDefaults'; - } + if (empty($aclType)) { + continue; + } - $defaultValue = $this->metadata - ->get( - ['app', $this->type, $paramDefaultsName, $aclType], - $this->metadata->get(['app', $this->type, $paramDefaultsName, 'record']) - ); + $paramDefaultsName = 'scopeLevelTypesDefaults'; - if (is_array($defaultValue)) { - $defaultValue = (object) $defaultValue; - } + if ($this->isStrictMode) { + $paramDefaultsName = 'scopeLevelTypesStrictDefaults'; + } - $table->$scope = $defaultValue; + $defaultValue = + $this->metadata->get(['app', $this->type, $paramDefaultsName, $aclType]) ?? + $this->metadata->get(['app', $this->type, $paramDefaultsName, 'record']); - if (is_object($table->$scope)) { - $actionList = $this->metadata->get(['scopes', $scope, $this->type . 'ActionList']); + if (is_array($defaultValue)) { + $defaultValue = (object) $defaultValue; + } - if ($actionList) { - foreach (get_object_vars($table->$scope) as $action => $level) { - if (!in_array($action, $actionList)) { - unset($table->$scope->$action); - } - } + $table->$scope = $defaultValue; + + if (is_object($table->$scope)) { + $actionList = $this->metadata->get(['scopes', $scope, $this->type . 'ActionList']); + + if ($actionList) { + foreach (get_object_vars($table->$scope) as $action => $level) { + if (!in_array($action, $actionList)) { + unset($table->$scope->$action); } } } @@ -641,7 +462,7 @@ class DefaultTable implements Table return; } - $data = $this->metadata->get('app.'.$this->type.'.mandatory.scopeLevel', []); + $data = $this->metadata->get(['app', $this->type, 'mandatory', 'scopeLevel']) ?? []; foreach ($data as $scope => $item) { $value = $item; @@ -653,23 +474,23 @@ class DefaultTable implements Table $table->$scope = $value; } - $mandatoryFieldData = $this->metadata->get('app.'.$this->type.'.mandatory.fieldLevel', []); + $mandatoryFieldData = $this->metadata->get(['app', $this->type, 'mandatory', 'fieldLevel']) ?? []; foreach ($this->getScopeList() as $scope) { if (isset($table->$scope) && $table->$scope === false) { continue; } - if (!$this->metadata->get('scopes.' . $scope . '.entity')) { + if (!$this->metadata->get(['scopes', $scope, 'entity'])) { continue; } - $fieldList = array_keys($this->metadata->get("entityDefs.{$scope}.fields", [])); + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); $mandatoryScopeFieldData = $this->metadata - ->get('app.'.$this->type.'.mandatory.scopeFieldLevel.' . $scope, []); + ->get(['app', $this->type, 'mandatory', 'scopeFieldLevel', $scope]) ?? []; - foreach (array_merge($mandatoryFieldData, $mandatoryScopeFieldData) as $field => $f) { + foreach (array_merge($mandatoryFieldData, $mandatoryScopeFieldData) as $field => $item) { if (!in_array($field, $fieldList)) { continue; } @@ -683,12 +504,12 @@ class DefaultTable implements Table foreach ($this->fieldActionList as $action) { $level = self::LEVEL_NO; - if ($f === true) { + if ($item === true) { $level = self::LEVEL_YES; } else { - if (is_array($f) && isset($f[$action])) { - $level = $f[$action]; + if (is_array($item) && isset($item[$action])) { + $level = $item[$action]; } } @@ -705,7 +526,7 @@ class DefaultTable implements Table } foreach ($this->getScopeList() as $scope) { - if ($this->metadata->get('scopes.' . $scope . '.disabled')) { + if ($this->metadata->get(['scopes', $scope, 'disabled'])) { $table->$scope = false; unset($fieldTable->$scope); @@ -713,6 +534,9 @@ class DefaultTable implements Table } } + /** + * @todo Revise usage of this method. + */ protected function applyAdditional(&$table, &$fieldTable, &$valuePermissionLists): void { if ($this->user->isPortal()) { @@ -836,7 +660,7 @@ class DefaultTable implements Table } $actionList = $this->metadata - ->get(['scopes', $scope, $this->type . 'ActionList'], $this->actionList); + ->get(['scopes', $scope, $this->type . 'ActionList']) ?? $this->actionList; foreach ($actionList as $i => $action) { if (isset($row->$action)) { @@ -891,7 +715,7 @@ class DefaultTable implements Table continue; } - $fieldList = array_keys($this->metadata->get("entityDefs.{$scope}.fields", [])); + $fieldList = array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); foreach (get_object_vars($table->$scope) as $field => $row) { if (!is_object($row)) { @@ -933,9 +757,4 @@ class DefaultTable implements Table return $data; } - - private function buildCache(): void - { - $this->dataCache->store($this->cacheKey, $this->data); - } } diff --git a/application/Espo/Core/Acl/FieldData.php b/application/Espo/Core/Acl/FieldData.php new file mode 100644 index 0000000000..1dbad91e6e --- /dev/null +++ b/application/Espo/Core/Acl/FieldData.php @@ -0,0 +1,96 @@ +actionData[$action] ?? Table::LEVEL_NO; + } + + /** + * Get a 'read' level. + */ + public function getRead(): string + { + return $this->get(Table::ACTION_READ); + } + + /** + * Get an 'edit' level. + */ + public function getEdit(): string + { + return $this->get(Table::ACTION_EDIT); + } + + /** + * Create from a raw table value. + */ + public static function fromRaw(StdClass $raw): self + { + $obj = new self(); + + $obj->actionData = get_object_vars($raw); + + foreach ($obj->actionData as $item) { + if (!is_string($item)) { + throw new RuntimeException("Bad raw scope data."); + } + } + + $obj->raw = $raw; + + return $obj; + } +} diff --git a/application/Espo/Core/Acl/Map/CacheKeyProvider.php b/application/Espo/Core/Acl/Map/CacheKeyProvider.php new file mode 100644 index 0000000000..9d5a565bf1 --- /dev/null +++ b/application/Espo/Core/Acl/Map/CacheKeyProvider.php @@ -0,0 +1,35 @@ +metadataProvider = $metadataProvider; + $this->fieldUtil = $fieldUtil; + } + + public function build(Table $table): StdClass + { + $data = (object) [ + 'table' => (object) [], + 'fieldTable' => (object) [], + ]; + + foreach ($this->metadataProvider->getScopeList() as $scope) { + $data->table->$scope = $this->getScopeRawData($table, $scope); + + $fieldData = $this->getScopeFieldData($table, $scope); + + if ($fieldData !== null) { + $data->fieldTable->$scope = $fieldData; + } + } + + foreach ($this->metadataProvider->getPermissionList() as $permission) { + $data->{$permission . 'Permission'} = $table->getPermissionLevel($permission); + } + + $data->fieldTableQuickAccess = $this->buildFieldTableQuickAccess($data->fieldTable); + + return $data; + } + + /** + * @return bool|StdClass + */ + private function getScopeRawData(Table $table, string $scope) + { + $data = $table->getScopeData($scope); + + if ($data->isBoolean()) { + return $data->isTrue(); + } + + $rawData = (object) []; + + foreach ($this->actionList as $action) { + $rawData->$action = $data->get($action); + } + + return $rawData; + } + + private function getScopeFieldData(Table $table, string $scope): ?StdClass + { + if (!$this->metadataProvider->isScopeEntity($scope)) { + return null; + } + + $fieldList = $this->metadataProvider->getScopeFieldList($scope); + + $rawData = (object) []; + + foreach ($fieldList as $field) { + $data = $table->getFieldData($scope, $field); + + if ( + $data->getRead() === Table::LEVEL_YES && + $data->getEdit() === Table::LEVEL_YES + ) { + continue; + } + + $rawData->$field = (object) [ + Table::ACTION_READ => $data->getRead(), + Table::ACTION_EDIT => $data->getEdit(), + ]; + } + + return $rawData; + } + + protected function buildFieldTableQuickAccess(StdClass $fieldTable): StdClass + { + $quickAccess = (object) []; + + foreach (get_object_vars($fieldTable) as $scope => $scopeData) { + $quickAccess->$scope = $this->buildFieldTableQuickAccessScope($scope, $scopeData); + } + + return $quickAccess; + } + + private function buildFieldTableQuickAccessScope(string $scope, StdClass $data): StdClass + { + $quickAccess = (object) [ + 'attributes' => (object) [], + 'fields' => (object) [], + ]; + + foreach ($this->fieldActionList as $action) { + $quickAccess->attributes->$action = (object) []; + $quickAccess->fields->$action = (object) []; + + foreach ($this->fieldLevelList as $level) { + $quickAccess->attributes->$action->$level = []; + $quickAccess->fields->$action->$level = []; + } + } + + foreach (get_object_vars($data) as $field => $fieldData) { + $attributeList = $this->fieldUtil->getAttributeList($scope, $field); + + foreach ($this->fieldActionList as $action) { + if (!isset($fieldData->$action)) { + continue; + } + + foreach ($this->fieldLevelList as $level) { + if ($fieldData->$action === $level) { + $quickAccess->fields->$action->{$level}[] = $field; + + foreach ($attributeList as $attribute) { + $quickAccess->attributes->$action->{$level}[] = $attribute; + } + } + } + } + } + + return $quickAccess; + } +} diff --git a/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php b/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php new file mode 100644 index 0000000000..ec3e624b70 --- /dev/null +++ b/application/Espo/Core/Acl/Map/DefaultCacheKeyProvider.php @@ -0,0 +1,47 @@ +user = $user; + } + + public function get(): string + { + return 'aclMap/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Acl/Map/Map.php b/application/Espo/Core/Acl/Map/Map.php new file mode 100644 index 0000000000..219e82a9a9 --- /dev/null +++ b/application/Espo/Core/Acl/Map/Map.php @@ -0,0 +1,251 @@ +user = $user; + $this->table = $table; + $this->dataBuilder = $dataBuilder; + $this->config = $config; + $this->dataCache = $dataCache; + + $this->cacheKey = $cacheKeyProvider->get(); + + if ($this->config->get('useCache') && $this->dataCache->has($this->cacheKey)) { + $this->data = $this->dataCache->get($this->cacheKey); + } + else { + $this->data = $this->dataBuilder->build($table); + + if ($this->config->get('useCache')) { + $this->dataCache->store($this->cacheKey, $this->data); + } + } + } + + /** + * Get raw data (for front-end). + */ + public function getData(): StdClass + { + return ObjectUtil::clone($this->data); + } + + /** + * Get a list of forbidden attributes for a scope and action. + * + * @param $scope A scope. + * @param $action An action. + * @param $thresholdLevel An attribute will be treated as forbidden if the level is + * equal to or lower than the threshold. + * @return array + */ + public function getScopeForbiddenAttributeList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + if ( + !in_array($thresholdLevel, $this->fieldLevelList) || + $thresholdLevel === Table::LEVEL_YES + ) { + throw new RuntimeException("Bad threshold level."); + } + + $key = $scope . '_'. $action . '_' . $thresholdLevel; + + if (isset($this->forbiddenAttributesCache[$key])) { + return $this->forbiddenAttributesCache[$key]; + } + + $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + + if ( + !isset($fieldTableQuickAccess->$scope) || + !isset($fieldTableQuickAccess->$scope->attributes) || + !isset($fieldTableQuickAccess->$scope->attributes->$action) + ) { + $this->forbiddenAttributesCache[$key] = []; + + return []; + } + + $levelList = []; + + foreach ($this->fieldLevelList as $level) { + if ( + array_search($level, $this->fieldLevelList) >= + array_search($thresholdLevel, $this->fieldLevelList) + ) { + $levelList[] = $level; + } + } + + $attributeList = []; + + foreach ($levelList as $level) { + if (!isset($fieldTableQuickAccess->$scope->attributes->$action->$level)) { + continue; + } + + foreach ($fieldTableQuickAccess->$scope->attributes->$action->$level as $attribute) { + if (in_array($attribute, $attributeList)) { + continue; + } + + $attributeList[] = $attribute; + } + } + + $this->forbiddenAttributesCache[$key] = $attributeList; + + return $attributeList; + } + + /** + * Get a list of forbidden fields for a scope and action. + * + * @param $scope A scope. + * @param $action An action. + * @param $thresholdLevel An attribute will be treated as forbidden if the level is + * equal to or lower than the threshold. + * @return array + */ + public function getScopeForbiddenFieldList( + string $scope, + string $action = Table::ACTION_READ, + string $thresholdLevel = Table::LEVEL_NO + ): array { + + if ( + !in_array($thresholdLevel, $this->fieldLevelList) || + $thresholdLevel === Table::LEVEL_YES + ) { + throw new RuntimeException("Bad threshold level."); + } + + $key = $scope . '_'. $action . '_' . $thresholdLevel; + + if (isset($this->forbiddenFieldsCache[$key])) { + return $this->forbiddenFieldsCache[$key]; + } + + $fieldTableQuickAccess = $this->data->fieldTableQuickAccess; + + if ( + !isset($fieldTableQuickAccess->$scope) || + !isset($fieldTableQuickAccess->$scope->fields) || + !isset($fieldTableQuickAccess->$scope->fields->$action) + ) { + $this->forbiddenFieldsCache[$key] = []; + + return []; + } + + $levelList = []; + + foreach ($this->fieldLevelList as $level) { + if ( + array_search($level, $this->fieldLevelList) >= + array_search($thresholdLevel, $this->fieldLevelList) + ) { + $levelList[] = $level; + } + } + + $fieldList = []; + + foreach ($levelList as $level) { + if (!isset($fieldTableQuickAccess->$scope->fields->$action->$level)) { + continue; + } + + foreach ($fieldTableQuickAccess->$scope->fields->$action->$level as $field) { + if (in_array($field, $fieldList)) { + continue; + } + + $fieldList[] = $field; + } + } + + $this->forbiddenFieldsCache[$key] = $fieldList; + + return $fieldList; + } +} diff --git a/application/Espo/Core/Acl/Map/MapFactory.php b/application/Espo/Core/Acl/Map/MapFactory.php new file mode 100644 index 0000000000..fa8e8f6508 --- /dev/null +++ b/application/Espo/Core/Acl/Map/MapFactory.php @@ -0,0 +1,82 @@ +injectableFactory = $injectableFactory; + } + + public function create(User $user, Table $table): Map + { + $bindingContainer = $this->createBindingContainer($user, $table); + + return $this->injectableFactory->createWithBinding(Map::class, $bindingContainer); + } + + private function createBindingContainer(User $user, Table $table): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindCallback( + User::class, + function () use ($user): User { + return $user; + } + ) + ->bindCallback( + Table::class, + function () use ($table): Table { + return $table; + } + ) + ->bindImplementation(CacheKeyProvider::class, DefaultCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Acl/Map/MetadataProvider.php b/application/Espo/Core/Acl/Map/MetadataProvider.php new file mode 100644 index 0000000000..07d1585a3c --- /dev/null +++ b/application/Espo/Core/Acl/Map/MetadataProvider.php @@ -0,0 +1,86 @@ +metadata = $metadata; + } + + /** + * @return array + */ + public function getScopeList(): array + { + return array_keys($this->metadata->get('scopes') ?? []); + } + + public function isScopeEntity(string $scope): bool + { + return (bool) $this->metadata->get(['scopes', $scope, 'entity']); + } + + /** + * @return array + */ + public function getScopeFieldList(string $scope): array + { + return array_keys($this->metadata->get(['entityDefs', $scope, 'fields']) ?? []); + } + + /** + * @return array + */ + public function getPermissionList(): array + { + $itemList = $this->metadata->get(['app', $this->type, 'valuePermissionList']) ?? []; + + return array_map( + function (string $item): string { + if (substr($item, -10) === 'Permission') { + return substr($item, 0, -10); + } + + return $item; + }, + $itemList + ); + } +} diff --git a/application/Espo/Core/Acl/Table.php b/application/Espo/Core/Acl/Table.php index 6a197da534..7e29750ef0 100644 --- a/application/Espo/Core/Acl/Table.php +++ b/application/Espo/Core/Acl/Table.php @@ -29,10 +29,8 @@ namespace Espo\Core\Acl; -use StdClass; - /** - * Contains access levels for a user. + * Access levels for a user. */ interface Table { @@ -56,40 +54,18 @@ interface Table public const ACTION_CREATE = 'create'; - /** - * Get a full map. - */ - public function getMap(): StdClass; - /** * Get scope data. */ public function getScopeData(string $scope): ScopeData; + /** + * Get field data. + */ + public function getFieldData(string $scope, string $field): FieldData; + /** * Get a permission level. */ public function getPermissionLevel(string $permission): string; - - /** - * Get a list of forbidden attributes for a scope and action. - * - * @param $scope A scope. - * $param $action An action. - * @param $thresholdLevel An attribute will be treated as forbidden if the level is - * equal to or lower than the threshold. - * @return array - */ - public function getScopeForbiddenAttributeList(string $scope, string $action, string $thresholdLevel): array; - - /** - * Get a list of forbidden fields for a scope and action. - * - * @param $scope A scope. - * $param $action An action. - * @param $thresholdLevel An attribute will be treated as forbidden if the level is - * equal to or lower than the threshold. - * @return array - */ - public function getScopeForbiddenFieldList(string $scope, string $action, string $thresholdLevel): array; } diff --git a/application/Espo/Core/Acl/Table/CacheKeyProvider.php b/application/Espo/Core/Acl/Table/CacheKeyProvider.php new file mode 100644 index 0000000000..bf1accfb2a --- /dev/null +++ b/application/Espo/Core/Acl/Table/CacheKeyProvider.php @@ -0,0 +1,35 @@ +user = $user; + } + + public function get(): string + { + return 'acl/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php b/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php new file mode 100644 index 0000000000..c17f2c641f --- /dev/null +++ b/application/Espo/Core/Acl/Table/DefaultRoleListProvider.php @@ -0,0 +1,90 @@ +user = $user; + $this->entityManager = $entityManager; + } + + /** + * @return array + */ + public function get(): array + { + $roleList = []; + + $userRoleList = $this->entityManager + ->getRepository('User') + ->getRelation($this->user, 'roles') + ->find(); + + foreach ($userRoleList as $role) { + $roleList[] = $role; + } + + $teamList = $this->entityManager + ->getRepository('User') + ->getRelation($this->user, 'teams') + ->find(); + + foreach ($teamList as $team) { + $teamRoleList = $this->entityManager + ->getRepository('Team') + ->getRelation($team, 'roles') + ->find(); + + foreach ($teamRoleList as $role) { + $roleList[] = $role; + } + } + + return array_map( + function (RoleEntity $role): RoleEntityWrapper { + return new RoleEntityWrapper($role); + }, + $roleList + ); + } +} diff --git a/application/Espo/Core/Acl/Table/Role.php b/application/Espo/Core/Acl/Table/Role.php new file mode 100644 index 0000000000..14b45cac6a --- /dev/null +++ b/application/Espo/Core/Acl/Table/Role.php @@ -0,0 +1,41 @@ +entity = $entity; + } + + public function getScopeTableData(): StdClass + { + return $this->entity->get('data') ?? (object) []; + } + + public function getFieldTableData(): StdClass + { + return $this->entity->get('fieldData') ?? (object) []; + } + + public function getPermissionLevel(string $permission): ?string + { + return $this->entity->get($permission . 'Permission'); + } +} diff --git a/application/Espo/Core/Acl/Table/RoleListProvider.php b/application/Espo/Core/Acl/Table/RoleListProvider.php new file mode 100644 index 0000000000..16e17bcc70 --- /dev/null +++ b/application/Espo/Core/Acl/Table/RoleListProvider.php @@ -0,0 +1,38 @@ + + */ + public function get(): array; +} diff --git a/application/Espo/Core/Acl/TableFactory.php b/application/Espo/Core/Acl/TableFactory.php index af4c8515cf..cfdeee917d 100644 --- a/application/Espo/Core/Acl/TableFactory.php +++ b/application/Espo/Core/Acl/TableFactory.php @@ -33,6 +33,13 @@ use Espo\Entities\User; use Espo\Core\{ InjectableFactory, + Acl\Table\CacheKeyProvider, + Acl\Table\DefaultCacheKeyProvider, + Acl\Table\RoleListProvider, + Acl\Table\DefaultRoleListProvider, + Binding\BindingContainer, + Binding\Binder, + Binding\BindingData, }; class TableFactory @@ -51,8 +58,27 @@ class TableFactory */ public function create(User $user): Table { - return $this->injectableFactory->createWith(DefaultTable::class, [ - 'user' => $user, - ]); + $bindingContainer = $this->createBindingContainer($user); + + return $this->injectableFactory->createWithBinding(DefaultTable::class, $bindingContainer); + } + + private function createBindingContainer(User $user): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindCallback( + User::class, + function () use ($user): User { + return $user; + } + ) + ->bindImplementation(RoleListProvider::class, DefaultRoleListProvider::class) + ->bindImplementation(CacheKeyProvider::class, DefaultCacheKeyProvider::class); + + return new BindingContainer($bindingData); } } diff --git a/application/Espo/Core/AclManager.php b/application/Espo/Core/AclManager.php index 578eb212aa..885bc8dbc8 100644 --- a/application/Espo/Core/AclManager.php +++ b/application/Espo/Core/AclManager.php @@ -40,6 +40,8 @@ use Espo\Core\{ Acl\OwnerUserFieldProvider, Acl\TableFactory, Acl\Table, + Acl\Map\Map, + Acl\Map\MapFactory, Acl\OwnershipCheckerFactory, Acl\OwnershipChecker, Acl\OwnershipOwnChecker, @@ -73,6 +75,8 @@ class AclManager protected $tableHashMap = []; + protected $mapHashMap = []; + protected $userAclClassName = Acl::class; protected const PERMISSION_ASSIGNMENT = 'assignment'; @@ -99,6 +103,8 @@ class AclManager protected $tableFactory; + protected $mapFactory; + protected $globalRestricton; protected $ownerUserFieldProvider; @@ -109,6 +115,7 @@ class AclManager AccessCheckerFactory $accessCheckerFactory, OwnershipCheckerFactory $ownershipCheckerFactory, TableFactory $tableFactory, + MapFactory $mapFactory, GlobalRestricton $globalRestricton, OwnerUserFieldProvider $ownerUserFieldProvider, EntityManager $entityManager @@ -116,6 +123,7 @@ class AclManager $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; $this->tableFactory = $tableFactory; + $this->mapFactory = $mapFactory; $this->globalRestricton = $globalRestricton; $this->ownerUserFieldProvider = $ownerUserFieldProvider; $this->entityManager = $entityManager; @@ -160,12 +168,27 @@ class AclManager return $this->tableHashMap[$key]; } - /** - * Get a full access data map. - */ - public function getMap(User $user): StdClass + protected function getMap(User $user): Map { - return $this->getTable($user)->getMap(); + $key = $user->getId(); + + if (!$key) { + $key = spl_object_hash($user); + } + + if (!array_key_exists($key, $this->mapHashMap)) { + $this->mapHashMap[$key] = $this->mapFactory->create($user, $this->getTable($user)); + } + + return $this->mapHashMap[$key]; + } + + /** + * Get a full access data map (for front-end). + */ + public function getMapData(User $user): StdClass + { + return $this->getMap($user)->getData(); } /** @@ -437,7 +460,7 @@ class AclManager ): array { $list = array_merge( - $this->getTable($user)->getScopeForbiddenAttributeList( + $this->getMap($user)->getScopeForbiddenAttributeList( $scope, $action, $thresholdLevel @@ -465,7 +488,7 @@ class AclManager ): array { $list = array_merge( - $this->getTable($user)->getScopeForbiddenFieldList( + $this->getMap($user)->getScopeForbiddenFieldList( $scope, $action, $thresholdLevel diff --git a/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php b/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php new file mode 100644 index 0000000000..5bfdddaaff --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/CacheKeyProvider.php @@ -0,0 +1,57 @@ +user = $user; + $this->portal = $portal; + } + + public function get(): string + { + return 'aclPortalMap/' . $this->portal->getId() . '/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Portal/Acl/Map/MapFactory.php b/application/Espo/Core/Portal/Acl/Map/MapFactory.php new file mode 100644 index 0000000000..51b299580a --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/MapFactory.php @@ -0,0 +1,97 @@ +injectableFactory = $injectableFactory; + } + + public function create(User $user, PortalTable $table, Portal $portal): Map + { + $bindingContainer = $this->createBindingContainer($user, $table, $portal); + + return $this->injectableFactory->createWithBinding(Map::class, $bindingContainer); + } + + private function createBindingContainer(User $user, PortalTable $table, Portal $portal): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindCallback( + User::class, + function () use ($user): User { + return $user; + } + ) + ->bindCallback( + Table::class, + function () use ($table): PortalTable { + return $table; + } + ) + ->bindCallback( + Portal::class, + function () use ($portal): Portal { + return $portal; + } + ) + ->bindImplementation(MetadataProvider::class, PortalMetadataProvider::class) + ->bindImplementation(CacheKeyProvider::class, PortalCacheKeyProvider::class); + + return new BindingContainer($bindingData); + } +} diff --git a/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php b/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php new file mode 100644 index 0000000000..a19b7b977b --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Map/MetadataProvider.php @@ -0,0 +1,39 @@ +portal = $portal; - - parent::__construct($entityManager, $user, $config, $metadata, $fieldUtil, $dataCache); - } - - protected function initCacheKey(): void - { - $this->cacheKey = 'aclPortal/' . $this->portal->getId() . '/' . $this->user->getId(); - } - - protected function getRoleList(): array - { - $roleList = []; - - $userRoleList = $this->entityManager - ->getRepository('User') - ->getRelation($this->user, 'portalRoles') - ->find(); - - foreach ($userRoleList as $role) { - $roleList[] = $role; - } - - $portalRoleList = $this->entityManager - ->getRepository('Portal') - ->getRelation($this->portal, 'portalRoles') - ->find(); - - foreach ($portalRoleList as $role) { - $roleList[] = $role; - } - - return $roleList; - } - protected function getScopeWithAclList(): array { $scopeList = []; @@ -151,9 +89,9 @@ class Table extends BaseTable protected function applyDisabled(&$table, &$fieldTable): void { foreach ($this->getScopeList() as $scope) { - $d = $this->metadata->get('scopes.' . $scope); + $item = $this->metadata->get(['scopes', $scope]) ?? []; - if (!empty($d['disabled']) || !empty($d['portalDisabled'])) { + if (!empty($item['disabled']) || !empty($item['portalDisabled'])) { $table->$scope = false; unset($fieldTable->$scope); diff --git a/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php b/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php new file mode 100644 index 0000000000..4d23420c78 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table/CacheKeyProvider.php @@ -0,0 +1,57 @@ +user = $user; + $this->portal = $portal; + } + + public function get(): string + { + return 'aclPortal/' . $this->portal->getId() . '/' . $this->user->getId(); + } +} diff --git a/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php b/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php new file mode 100644 index 0000000000..9b63ecc774 --- /dev/null +++ b/application/Espo/Core/Portal/Acl/Table/RoleListProvider.php @@ -0,0 +1,93 @@ +user = $user; + $this->portal = $portal; + $this->entityManager = $entityManager; + } + + /** + * @return array + */ + public function get(): array + { + $roleList = []; + + $userRoleList = $this->entityManager + ->getRepository('User') + ->getRelation($this->user, 'portalRoles') + ->find(); + + foreach ($userRoleList as $role) { + $roleList[] = $role; + } + + $portalRoleList = $this->entityManager + ->getRepository('Portal') + ->getRelation($this->portal, 'portalRoles') + ->find(); + + foreach ($portalRoleList as $role) { + $roleList[] = $role; + } + + return array_map( + function (PortalRole $role): RoleEntityWrapper { + return new RoleEntityWrapper($role); + }, + $roleList + ); + } +} diff --git a/application/Espo/Core/Portal/Acl/TableFactory.php b/application/Espo/Core/Portal/Acl/TableFactory.php index 14f1611711..02a299054f 100644 --- a/application/Espo/Core/Portal/Acl/TableFactory.php +++ b/application/Espo/Core/Portal/Acl/TableFactory.php @@ -36,6 +36,13 @@ use Espo\Entities\{ use Espo\Core\{ InjectableFactory, + Acl\Table\CacheKeyProvider, + Portal\Acl\Table\CacheKeyProvider as PortalCacheKeyProvider, + Acl\Table\RoleListProvider, + Portal\Acl\Table\RoleListProvider as PortalRoleListProvider, + Binding\BindingContainer, + Binding\Binder, + Binding\BindingData, }; class TableFactory @@ -52,9 +59,33 @@ class TableFactory */ public function create(User $user, Portal $portal): Table { - return $this->injectableFactory->createWith(Table::class, [ - 'user' => $user, - 'portal' => $portal, - ]); + $bindingContainer = $this->createBindingContainer($user, $portal); + + return $this->injectableFactory->createWithBinding(Table::class, $bindingContainer); + } + + private function createBindingContainer(User $user, Portal $portal): BindingContainer + { + $bindingData = new BindingData(); + + $binder = new Binder($bindingData); + + $binder + ->bindCallback( + User::class, + function () use ($user): User { + return $user; + } + ) + ->bindCallback( + Portal::class, + function () use ($portal): Portal { + return $portal; + } + ) + ->bindImplementation(RoleListProvider::class, PortalRoleListProvider::class) + ->bindImplementation(CacheKeyProvider::class, PortalCacheKeyProvider::class); + + return new BindingContainer($bindingData); } } diff --git a/application/Espo/Core/Portal/AclManager.php b/application/Espo/Core/Portal/AclManager.php index 9089431a74..1488fcafb2 100644 --- a/application/Espo/Core/Portal/AclManager.php +++ b/application/Espo/Core/Portal/AclManager.php @@ -47,9 +47,11 @@ use Espo\Core\{ Portal\Acl\OwnershipContactChecker, Portal\Acl\TableFactory, Portal\Acl, + Portal\Acl\Map\MapFactory, Acl\GlobalRestricton, Acl\OwnerUserFieldProvider, Acl\Table as TableBase, + Acl\Map\Map, AclManager as InternalAclManager, }; @@ -68,6 +70,7 @@ class AclManager extends InternalAclManager AccessCheckerFactory $accessCheckerFactory, OwnershipCheckerFactory $ownershipCheckerFactory, TableFactory $tableFactory, + MapFactory $mapFactory, GlobalRestricton $globalRestricton, OwnerUserFieldProvider $ownerUserFieldProvider, EntityManager $entityManager, @@ -76,6 +79,7 @@ class AclManager extends InternalAclManager $this->accessCheckerFactory = $accessCheckerFactory; $this->ownershipCheckerFactory = $ownershipCheckerFactory; $this->tableFactory = $tableFactory; + $this->mapFactory = $mapFactory; $this->globalRestricton = $globalRestricton; $this->ownerUserFieldProvider = $ownerUserFieldProvider; $this->entityManager = $entityManager; @@ -111,13 +115,29 @@ class AclManager extends InternalAclManager return $this->tableHashMap[$key]; } - public function getMap(User $user): StdClass + protected function getMap(User $user): Map { - if ($this->checkUserIsNotPortal($user)) { - return $this->internalAclManager->getMap($user); + $key = $user->getId(); + + if (!$key) { + $key = spl_object_hash($user); } - return parent::getMap($user); + if (!array_key_exists($key, $this->mapHashMap)) { + $this->mapHashMap[$key] = $this->mapFactory + ->create($user, $this->getTable($user), $this->getPortal()); + } + + return $this->mapHashMap[$key]; + } + + public function getMapData(User $user): StdClass + { + if ($this->checkUserIsNotPortal($user)) { + return $this->internalAclManager->getMapData($user); + } + + return parent::getMapData($user); } public function getLevel(User $user, string $scope, string $action): string @@ -285,7 +305,8 @@ class AclManager extends InternalAclManager ): array { if ($this->checkUserIsNotPortal($user)) { - return $this->internalAclManager->getScopeForbiddenAttributeList($user, $scope, $action, $thresholdLevel); + return $this->internalAclManager + ->getScopeForbiddenAttributeList($user, $scope, $action, $thresholdLevel); } return parent::getScopeForbiddenAttributeList($user, $scope, $action, $thresholdLevel); @@ -299,7 +320,8 @@ class AclManager extends InternalAclManager ): array { if ($this->checkUserIsNotPortal($user)) { - return $this->internalAclManager->getScopeForbiddenFieldList($user, $scope, $action, $thresholdLevel); + return $this->internalAclManager + ->getScopeForbiddenFieldList($user, $scope, $action, $thresholdLevel); } return parent::getScopeForbiddenFieldList($user, $scope, $action, $thresholdLevel); diff --git a/application/Espo/Services/App.php b/application/Espo/Services/App.php index b105d1af73..bccb176195 100644 --- a/application/Espo/Services/App.php +++ b/application/Espo/Services/App.php @@ -214,7 +214,7 @@ class App protected function getAclDataForFrontend() { - $data = $this->acl->getMap(); + $data = $this->acl->getMapData(); if (!$this->user->isAdmin()) { $data = unserialize(serialize($data)); diff --git a/application/Espo/Services/Portal.php b/application/Espo/Services/Portal.php index 8641604afe..cbdc92e451 100644 --- a/application/Espo/Services/Portal.php +++ b/application/Espo/Services/Portal.php @@ -77,6 +77,8 @@ class Portal extends Record implements protected function clearRolesCache() { $this->fileManager->removeInDir('data/cache/application/aclPortal'); + $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); + $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Services/PortalRole.php b/application/Espo/Services/PortalRole.php index a896f292e9..60b7897ac3 100644 --- a/application/Espo/Services/PortalRole.php +++ b/application/Espo/Services/PortalRole.php @@ -58,6 +58,8 @@ class PortalRole extends Record implements protected function clearRolesCache() { $this->fileManager->removeInDir('data/cache/application/aclPortal'); + $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); + $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Services/Role.php b/application/Espo/Services/Role.php index 317b3675e0..29e4abfaa5 100644 --- a/application/Espo/Services/Role.php +++ b/application/Espo/Services/Role.php @@ -58,6 +58,8 @@ class Role extends Record implements protected function clearRolesCache() { $this->fileManager->removeInDir('data/cache/application/acl'); + $this->fileManager->removeInDir('data/cache/application/aclMap'); + $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Services/Team.php b/application/Espo/Services/Team.php index 7018805927..445d590514 100644 --- a/application/Espo/Services/Team.php +++ b/application/Espo/Services/Team.php @@ -44,6 +44,7 @@ class Team extends Record implements public function afterUpdateEntity(Entity $entity, $data) { parent::afterUpdateEntity($entity, $data); + if (property_exists($data, 'rolesIds')) { $this->clearRolesCache(); } @@ -52,6 +53,8 @@ class Team extends Record implements protected function clearRolesCache() { $this->fileManager->removeInDir('data/cache/application/acl'); + $this->fileManager->removeInDir('data/cache/application/aclMap'); + $this->dataManager->updateCacheTimestamp(); } @@ -61,6 +64,8 @@ class Team extends Record implements if ($link === 'users') { $this->fileManager->removeFile('data/cache/application/acl/' . $foreignId . '.php'); + $this->fileManager->removeFile('data/cache/application/aclMap/' . $foreignId . '.php'); + $this->dataManager->updateCacheTimestamp(); } } @@ -71,6 +76,8 @@ class Team extends Record implements if ($link === 'users') { $this->fileManager->removeFile('data/cache/application/acl/' . $foreignId . '.php'); + $this->fileManager->removeFile('data/cache/application/aclMap/' . $foreignId . '.php'); + $this->dataManager->updateCacheTimestamp(); } } diff --git a/application/Espo/Services/User.php b/application/Espo/Services/User.php index 45d94b54d4..6dcfaf750a 100644 --- a/application/Espo/Services/User.php +++ b/application/Espo/Services/User.php @@ -771,6 +771,7 @@ class User extends Record implements protected function clearRoleCache(string $id) { $this->fileManager->removeFile('data/cache/application/acl/' . $id . '.php'); + $this->fileManager->removeFile('data/cache/application/aclMap/' . $id . '.php'); $this->dataManager->updateCacheTimestamp(); } @@ -778,11 +779,11 @@ class User extends Record implements protected function clearPortalRolesCache() { $this->fileManager->removeInDir('data/cache/application/aclPortal'); + $this->fileManager->removeInDir('data/cache/application/aclPortalMap'); $this->dataManager->updateCacheTimestamp(); } - public function loadAdditionalFields(Entity $entity) { parent::loadAdditionalFields($entity); diff --git a/client/src/acl-manager.js b/client/src/acl-manager.js index de1d68758f..5df5b5af40 100644 --- a/client/src/acl-manager.js +++ b/client/src/acl-manager.js @@ -96,13 +96,36 @@ define('acl-manager', ['acl'], function (Acl) { this.data.attributeTable = this.data.attributeTable || {}; }, + /** + * @deprecated Use `getPermissionLevel`. + * @returns string|null + */ get: function (name) { return this.data[name] || null; }, + /** + * @param string permission + * @returns string + */ + getPermissionLevel: function (permission) { + var permissionKey = permission; + + if (permission.substr(-10) !== 'Permission') { + permissionKey = permission + 'Permission'; + } + + return this.data[permissionKey] || 'no'; + }, + getLevel: function (scope, action) { - if (!(scope in this.data.table)) return; - if (typeof this.data.table[scope] !== 'object' || !(action in this.data.table[scope])) return; + if (!(scope in this.data.table)) { + return; + } + + if (typeof this.data.table[scope] !== 'object' || !(action in this.data.table[scope])) { + return; + } return this.data.table[scope][action]; }, @@ -113,17 +136,21 @@ define('acl-manager', ['acl'], function (Acl) { checkScopeHasAcl: function (scope) { var data = (this.data.table || {})[scope]; + if (typeof data === 'undefined') { return false; } + return true; }, checkScope: function (scope, action, precise) { var data = (this.data.table || {})[scope]; + if (typeof data === 'undefined') { data = null; } + return this.getImplementation(scope).checkScope(data, action, precise); }, @@ -143,6 +170,7 @@ define('acl-manager', ['acl'], function (Acl) { } var data = (this.data.table || {})[scope]; + if (typeof data === 'undefined') { data = null; } @@ -160,7 +188,8 @@ define('acl-manager', ['acl'], function (Acl) { check: function (subject, action, precise) { if (typeof subject === 'string') { return this.checkScope(subject, action, precise); - } else { + } + else { return this.checkModel(subject, action, precise); } }, @@ -230,6 +259,7 @@ define('acl-manager', ['acl'], function (Acl) { thresholdLevel = thresholdLevel || 'no'; var key = scope + '_' + action + '_' + thresholdLevel; + if (key in this.forbiddenFieldsCache) { return this.forbiddenFieldsCache[key]; } @@ -242,10 +272,15 @@ define('acl-manager', ['acl'], function (Acl) { var actionData = fieldsData[action] || {}; var fieldList = []; + levelList.forEach(function (level) { var list = actionData[level] || []; + list.forEach(function (field) { - if (~fieldList.indexOf(field)) return; + if (~fieldList.indexOf(field)) { + return; + } + fieldList.push(field); }, this); }, this); @@ -260,6 +295,7 @@ define('acl-manager', ['acl'], function (Acl) { thresholdLevel = thresholdLevel || 'no'; var key = scope + '_' + action + '_' + thresholdLevel; + if (key in this.forbiddenAttributesCache) { return this.forbiddenAttributesCache[key]; } @@ -273,10 +309,15 @@ define('acl-manager', ['acl'], function (Acl) { var actionData = attributesData[action] || {}; var attributeList = []; + levelList.forEach(function (level) { var list = actionData[level] || []; + list.forEach(function (attribute) { - if (~attributeList.indexOf(attribute)) return; + if (~attributeList.indexOf(attribute)) { + return; + } + attributeList.push(attribute); }, this); }, this); @@ -287,7 +328,10 @@ define('acl-manager', ['acl'], function (Acl) { }, checkTeamAssignmentPermission: function (teamId) { - if (this.get('assignmentPermission') === 'all') return true; + if (this.get('assignmentPermission') === 'all') { + return true; + } + return ~this.getUser().getLinkMultipleIdList('teams').indexOf(teamId); }, diff --git a/tests/unit/Espo/Core/Acl/FieldDataTest.php b/tests/unit/Espo/Core/Acl/FieldDataTest.php new file mode 100644 index 0000000000..094f70aa79 --- /dev/null +++ b/tests/unit/Espo/Core/Acl/FieldDataTest.php @@ -0,0 +1,79 @@ + Table::LEVEL_YES, + Table::ACTION_READ => Table::LEVEL_NO, + ]; + + $data = FieldData::fromRaw($raw); + + $this->assertEquals(Table::LEVEL_NO, $data->getRead()); + $this->assertEquals(Table::LEVEL_YES, $data->getEdit()); + } + + public function testGet2(): void + { + $raw = (object) [ + Table::ACTION_EDIT => Table::LEVEL_NO, + Table::ACTION_READ => Table::LEVEL_YES, + ]; + + $data = FieldData::fromRaw($raw); + + $this->assertEquals(Table::LEVEL_YES, $data->getRead()); + $this->assertEquals(Table::LEVEL_NO, $data->getEdit()); + } + + public function testGetEmpty(): void + { + $raw = (object) [ + ]; + + $data = FieldData::fromRaw($raw); + + $this->assertEquals(Table::LEVEL_NO, $data->getRead()); + $this->assertEquals(Table::LEVEL_NO, $data->getEdit()); + } +} diff --git a/tests/unit/Espo/Core/Acl/Map/MapTest.php b/tests/unit/Espo/Core/Acl/Map/MapTest.php new file mode 100644 index 0000000000..60f0bab990 --- /dev/null +++ b/tests/unit/Espo/Core/Acl/Map/MapTest.php @@ -0,0 +1,442 @@ +config = $this->createMock(Config::class); + $this->fieldUtil = $this->createMock(FieldUtil::class); + $this->table = $this->createMock(Table::class); + $this->user = $this->createMock(User::class); + $this->dataCache = $this->createMock(DataCache::class); + $this->metadataProvider = $this->createMock(MetadataProvider::class); + $this->cacheKeyProvider = $this->createMock(CacheKeyProvider::class); + + $this->config + ->expects($this->any()) + ->method('get') + ->willReturnMap([ + ['useCache', false] + ]); + + $this->user + ->expects($this->any()) + ->method('getId') + ->willReturn('user-id'); + } + + private function mockTableData(array $scopeData, array $fieldData, array $permissionData): void + { + $returnMap1 = []; + + foreach ($scopeData as $scope => $item) { + $returnMap1[] = [$scope, ScopeData::fromRaw($item)]; + } + + $this->table + ->expects($this->any()) + ->method('getScopeData') + ->willReturnMap($returnMap1); + + $returnMap2 = []; + + foreach ($fieldData as $scope => $item1) { + foreach ($item1 as $field => $item2) { + $returnMap2[] = [$scope, $field, FieldData::fromRaw($item2)]; + } + } + + $this->table + ->expects($this->any()) + ->method('getFieldData') + ->willReturnMap($returnMap2); + + $returnMap3 = []; + + foreach ($permissionData as $permission => $level) { + $returnMap3[] = [$permission, $level]; + } + + $this->table + ->expects($this->any()) + ->method('getPermissionLevel') + ->willReturnMap($returnMap3); + } + + public function testMap1(): void + { + $dataBuilder = new DataBuilder($this->metadataProvider, $this->fieldUtil); + + $this->metadataProvider + ->expects($this->any()) + ->method('getScopeList') + ->willReturn(['Test1', 'Test2', 'Test3', 'Test4', 'Test5']); + + $this->metadataProvider + ->expects($this->any()) + ->method('getPermissionList') + ->willReturn(['assignment', 'portal']); + + $this->metadataProvider + ->expects($this->any()) + ->method('isScopeEntity') + ->willReturnMap([ + ['Test1', true], + ['Test2', true], + ['Test3', true], + ['Test4', true], + ['Test5', false], + ]); + + $this->metadataProvider + ->expects($this->any()) + ->method('getScopeFieldList') + ->willReturnMap([ + ['Test1', ['field1', 'field2', 'field3', 'field4']], + ['Test2', ['field1']], + ['Test3', []], + ['Test4', []], + ['Test5', []], + ]); + + $this->fieldUtil + ->expects($this->any()) + ->method('getAttributeList') + ->willReturnMap([ + ['Test1', 'field1', ['attr1a', 'attr1b']], + ['Test1', 'field2', ['field2']], + ['Test1', 'field3', ['field3']], + ['Test1', 'field4', ['field4']], + ['Test2', 'field1', ['field1']], + ]); + + $this->mockTableData( + [ + 'Test1' => (object) [ + Table::ACTION_CREATE => Table::LEVEL_YES, + Table::ACTION_READ => Table::LEVEL_TEAM, + ], + 'Test2' => (object) [ + Table::ACTION_CREATE => Table::LEVEL_YES, + Table::ACTION_READ => Table::LEVEL_TEAM, + Table::ACTION_EDIT => Table::LEVEL_OWN, + ], + 'Test3' => false, + 'Test4' => true, + 'Test5' => true, + ], + [ + 'Test1' => [ + 'field1' => (object) [ + Table::ACTION_READ => Table::LEVEL_YES, + Table::ACTION_EDIT => Table::LEVEL_NO, + ], + 'field2' => (object) [ + Table::ACTION_READ => Table::LEVEL_NO, + Table::ACTION_EDIT => Table::LEVEL_YES, + ], + 'field3' => (object) [ + Table::ACTION_READ => Table::LEVEL_NO, + Table::ACTION_EDIT => Table::LEVEL_NO, + ], + 'field4' => (object) [ + Table::ACTION_READ => Table::LEVEL_YES, + Table::ACTION_EDIT => Table::LEVEL_YES, + ], + ], + 'Test2' => [ + 'field1' => (object) [ + Table::ACTION_READ => Table::LEVEL_NO, + Table::ACTION_EDIT => Table::LEVEL_NO, + ], + ], + ], + [ + 'assignment' => Table::LEVEL_YES, + 'portal' => Table::LEVEL_NO, + ], + ); + + $expectedData = $this->getExpectedRawData(); + + $map = new Map( + $this->user, + $this->table, + $dataBuilder, + $this->config, + $this->dataCache, + $this->cacheKeyProvider + ); + + $this->assertEquals($expectedData, $map->getData()); + + $this->assertEquals( + ['field2', 'field3'], + $map->getScopeForbiddenFieldList('Test1', Table::ACTION_READ) + ); + + $this->assertEquals( + ['field1', 'field3'], + $map->getScopeForbiddenFieldList('Test1', Table::ACTION_EDIT) + ); + + $this->assertEquals( + ['field2', 'field3'], + $map->getScopeForbiddenAttributeList('Test1', Table::ACTION_READ) + ); + + $this->assertEquals( + ['attr1a', 'attr1b', 'field3'], + $map->getScopeForbiddenAttributeList('Test1', Table::ACTION_EDIT) + ); + + $this->assertEquals( + ['attr1a', 'attr1b', 'field3'], + $map->getScopeForbiddenAttributeList('Test1', Table::ACTION_EDIT) + ); + + $this->assertEquals( + ['field1'], + $map->getScopeForbiddenFieldList('Test2', Table::ACTION_READ) + ); + + $this->assertEquals( + ['field1'], + $map->getScopeForbiddenFieldList('Test2', Table::ACTION_READ) + ); + + $this->assertEquals( + [], + $map->getScopeForbiddenFieldList('Test3', Table::ACTION_READ) + ); + } + + private function getExpectedRawData(): StdClass + { + return (object) [ + 'table' => (object) [ + 'Test1' => (object) [ + 'read' => 'team', + 'stream' => 'no', + 'edit' => 'no', + 'delete' => 'no', + 'create' => 'yes' + ], + 'Test2' => (object) [ + 'read' => 'team', + 'stream' => 'no', + 'edit' => 'own', + 'delete' => 'no', + 'create' => 'yes' + ], + 'Test3' => false, + 'Test4' => true, + 'Test5' => true + ], + 'fieldTable' => (object) [ + 'Test1' => (object) [ + 'field1' => (object) [ + 'read' => 'yes', + 'edit' => 'no' + ], + 'field2' => (object) [ + 'read' => 'no', + 'edit' => 'yes' + ], + 'field3' => (object) [ + 'read' => 'no', + 'edit' => 'no' + ] + ], + 'Test2' => (object) [ + 'field1' => (object) [ + 'read' => 'no', + 'edit' => 'no' + ] + ], + 'Test3' => (object) [], + 'Test4' => (object) [] + ], + 'assignmentPermission' => 'yes', + 'portalPermission' => 'no', + 'fieldTableQuickAccess' => (object) [ + 'Test1' => (object) [ + 'attributes' => (object) [ + 'read' => (object) [ + 'yes' => [ + 'attr1a', + 'attr1b' + ], + 'no' => [ + 'field2', + 'field3' + ] + ], + 'edit' => (object) [ + 'yes' => [ + 'field2' + ], + 'no' => [ + 'attr1a', + 'attr1b', + 'field3' + ] + ] + ], + 'fields' => (object) [ + 'read' => (object) [ + 'yes' => [ + 'field1' + ], + 'no' => [ + 'field2', + 'field3' + ] + ], + 'edit' => (object) [ + 'yes' => [ + 'field2' + ], + 'no' => [ + 'field1', + 'field3' + ] + ] + ] + ], + 'Test2' => (object) [ + 'attributes' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [ + 'field1' + ] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [ + 'field1' + ] + ] + ], + 'fields' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [ + 'field1' + ] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [ + 'field1' + ] + ] + ] + ], + 'Test3' => (object) [ + 'attributes' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [] + ] + ], + 'fields' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [] + ] + ] + ], + 'Test4' => (object) [ + 'attributes' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [] + ] + ], + 'fields' => (object) [ + 'read' => (object) [ + 'yes' => [], + 'no' => [] + ], + 'edit' => (object) [ + 'yes' => [], + 'no' => [] + ] + ] + ] + ] + ]; + } +} diff --git a/tests/unit/Espo/Core/Acl/Map/MetadataProviderTest.php b/tests/unit/Espo/Core/Acl/Map/MetadataProviderTest.php new file mode 100644 index 0000000000..f1abdb6f6f --- /dev/null +++ b/tests/unit/Espo/Core/Acl/Map/MetadataProviderTest.php @@ -0,0 +1,59 @@ +metadata = $this->createMock(Metadata::class); + } + + public function testGetPermissionList(): void + { + $provider = new MetadataProvider($this->metadata); + + $this->metadata + ->expects($this->once()) + ->method('get') + ->with(['app', 'acl', 'valuePermissionList']) + ->willReturn(['assignmentPermission', 'portalPermission']); + + $this->assertEquals(['assignment', 'portal'], $provider->getPermissionList()); + } +} diff --git a/tests/unit/Espo/Core/AclManagerTest.php b/tests/unit/Espo/Core/AclManagerTest.php index b9a9150e9b..161ed13966 100644 --- a/tests/unit/Espo/Core/AclManagerTest.php +++ b/tests/unit/Espo/Core/AclManagerTest.php @@ -37,6 +37,7 @@ use Espo\Core\{ Acl\OwnerUserFieldProvider, Acl\TableFactory, Acl\GlobalRestricton, + Acl\Map\MapFactory, ORM\EntityManager, }; @@ -72,10 +73,12 @@ class AclManagerTest extends \PHPUnit\Framework\TestCase private $globalRestriction; /** - * @var USer + * @var User */ private $user; + private $mapFactory; + protected function setUp(): void { $this->user = $this->createMock(User::class); @@ -84,12 +87,14 @@ class AclManagerTest extends \PHPUnit\Framework\TestCase $this->accessCheckerFactory = $this->createMock(AccessCheckerFactory::class); $this->ownershipCheckerFactory = $this->createMock(OwnershipCheckerFactory::class); $this->tableFactory = $this->createMock(TableFactory::class); + $this->mapFactory = $this->createMock(MapFactory::class); $this->globalRestriction = $this->createMock(GlobalRestricton::class); $this->aclManager = new AclManager( $this->accessCheckerFactory, $this->ownershipCheckerFactory, $this->tableFactory, + $this->mapFactory, $this->globalRestriction, $this->createMock(OwnerUserFieldProvider::class), $this->createMock(EntityManager::class)