diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index e15e965..57b668b 100644 --- a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php +++ b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php @@ -5,6 +5,7 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use Doctrine\ORM\QueryBuilder; +use Doctrine\Common\Annotations\Reader; use Ds\Component\Acl\Collection\EntityCollection; use Ds\Component\Acl\Service\AccessService; use Ds\Component\Acl\Exception\NoPermissionsException; @@ -12,7 +13,10 @@ use Ds\Component\Model\Type\Identitiable; use Ds\Component\Model\Type\Ownable; use Ds\Component\Model\Type\Uuidentifiable; +use Ds\Component\Translation\Model\Annotation\Translate; +use Ds\Component\Translation\Model\Type\Translatable; use LogicException; +use ReflectionClass; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; /** @@ -37,18 +41,25 @@ final class EntityExtension implements QueryCollectionExtensionInterface */ private $entityCollection; + /** + * @var \Doctrine\Common\Annotations\Reader + */ + private $annotationReader; + /** * Constructor * * @param \Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface $tokenStorage * @param \Ds\Component\Acl\Service\AccessService $accessService * @param \Ds\Component\Acl\Collection\EntityCollection $entityCollection + * @param \Doctrine\Common\Annotations\Reader $annotationReader */ - public function __construct(TokenStorageInterface $tokenStorage, AccessService $accessService, EntityCollection $entityCollection) + public function __construct(TokenStorageInterface $tokenStorage, AccessService $accessService, EntityCollection $entityCollection, Reader $annotationReader) { $this->tokenStorage = $tokenStorage; $this->accessService = $accessService; $this->entityCollection = $entityCollection; + $this->annotationReader = $annotationReader; } /** @@ -69,7 +80,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $user = $token->getUser(); $permissions = $this->accessService->getPermissions($user, true); $rootAlias = $queryBuilder->getRootAliases()[0]; - $conditions = []; + $wheres = []; $parameters = []; $i = 0; @@ -90,152 +101,434 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } - switch ($permission->getScope()->getType()) { - case 'generic': - // This permission grants access to all entities of the class, no conditions need to be applied. - return; - - case 'object': - if (!in_array(Uuidentifiable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "object" if the entity is not uuidentifiable. + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $subWheres = []; + + foreach ($conditions as $condition) { + $type = isset($condition['type']) ? $condition['type'] : null; + + switch ($type) { + case 'generic': + // This permission grants access to all entities of the class, no where conditions need to be applied. + return; + + case 'object': + if (!in_array(Uuidentifiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "object" if the entity is not uuidentifiable. + continue; + } + + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } + + $subWheres[] = $queryBuilder->expr()->eq($rootAlias . '.uuid', ':ds_security_uuid_' . $i); + $parameters['ds_security_uuid_' . $i] = $condition['entity_uuid']; + $i++; + + break; + + case 'identity': + if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "identity" if the entity is not identitiable. + continue; + } + + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } + + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } + + if (in_array($resourceClass, [ + 'App\\Entity\\Anonymous', + 'App\\Entity\\Individual', + 'App\\Entity\\Organization', + 'App\\Entity\\Staff' + ])) { + $subWheres[] = $queryBuilder->expr()->eq($rootAlias . '.uuid', ':ds_security_identity_' . $i); + $parameters['ds_security_identity_' . $i] = $condition['entity_uuid']; + } else if (in_array($resourceClass, [ + 'App\\Entity\\AnonymousPersona', + 'App\\Entity\\IndividualPersona', + 'App\\Entity\\OrganizationPersona', + 'App\\Entity\\StaffPersona' + ])) { + $identity = substr($resourceClass, 0, -7); + $alias = strtolower(substr($resourceClass, 17, -7)); + $subQueryBuilder = new QueryBuilder($queryBuilder->getEntityManager()); + $subWheres[] = $queryBuilder->expr()->in( + $rootAlias . '.' . $alias, + $subQueryBuilder + ->select($alias) + ->from($identity, $alias) + ->where($alias . '.uuid = :ds_security_identity_uuid_' . $i) + ->getDQL() + ); + $parameters['ds_security_identity_uuid_' . $i] = $condition['entity_uuid']; + } else { + $subWheres[] = $queryBuilder->expr()->andX( + $queryBuilder->expr()->eq($rootAlias . '.identity', ':ds_security_identity_' . $i), + $queryBuilder->expr()->eq($rootAlias . '.identityUuid', ':ds_security_identity_uuid_' . $i) + ); + $parameters['ds_security_identity_' . $i] = $condition['entity']; + $parameters['ds_security_identity_uuid_' . $i] = $condition['entity_entity']; + } + + $i++; + + break; + + case 'owner': + if (!in_array(Ownable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "owner" if the entity is not ownable. + continue; + } + + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } + + $entityUuid = isset($condition['entity_uuid']) ? $condition['entity_uuid'] : null; + + if (null === $entityUuid) { + $subWheres[] = $queryBuilder->expr()->eq($rootAlias . '.owner', ':ds_security_owner_' . $i); + $parameters['ds_security_owner_' . $i] = $condition['entity']; + } else { + $subWheres[] = $queryBuilder->expr()->andX( + $queryBuilder->expr()->eq($rootAlias . '.owner', ':ds_security_owner_' . $i), + $queryBuilder->expr()->eq($rootAlias . '.ownerUuid', ':ds_security_owner_uuid_' . $i) + ); + $parameters['ds_security_owner_' . $i] = $condition['entity']; + $parameters['ds_security_owner_uuid_' . $i] = $entityUuid; + } + + $i++; + + break; + + case 'session': + if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "session" if the entity is not identitiable. + continue; + } + + // @todo Refactor this exception handling at the entity level with metadata, the core should not know about these details. + if (in_array($resourceClass, [ + 'App\\Entity\\Anonymous', + 'App\\Entity\\Individual', + 'App\\Entity\\Organization', + 'App\\Entity\\Staff' + ])) { + $subWheres[] = $queryBuilder->expr()->eq($rootAlias . '.uuid', ':ds_security_identity_uuid_' . $i); + $parameters['ds_security_identity_uuid_' . $i] = $user->getIdentity()->getUuid(); + } else if (in_array($resourceClass, [ + 'App\\Entity\\AnonymousPersona', + 'App\\Entity\\IndividualPersona', + 'App\\Entity\\OrganizationPersona', + 'App\\Entity\\StaffPersona' + ])) { + $identity = substr($resourceClass, 0, -7); + $alias = strtolower(substr($resourceClass, 17, -7)); + $subQueryBuilder = new QueryBuilder($queryBuilder->getEntityManager()); + $subWheres[] = $queryBuilder->expr()->in( + $rootAlias . '.' . $alias, + $subQueryBuilder + ->select($alias) + ->from($identity, $alias) + ->where($alias . '.uuid = :ds_security_identity_uuid_' . $i) + ->getDQL() + ); + $parameters['ds_security_identity_uuid_' . $i] = $user->getIdentity()->getUuid(); + } else { + $subWheres[] = $queryBuilder->expr()->andX( + $queryBuilder->expr()->eq($rootAlias . '.identity', ':ds_security_identity_' . $i), + $queryBuilder->expr()->eq($rootAlias . '.identityUuid', ':ds_security_identity_uuid_' . $i) + ); + $parameters['ds_security_identity_' . $i] = $user->getIdentity()->getType(); + $parameters['ds_security_identity_uuid_' . $i] = $user->getIdentity()->getUuid(); + } + + $i++; + + break; + + case 'property': + $property = isset($condition['property']) ? $condition['property'] : null; + $value = isset($condition['value']) ? $condition['value'] : null; + $comparison = isset($condition['comparison']) ? $condition['comparison'] : 'eq'; + + if (null === $property) { + // Skip permissions that do not define a property. + continue; + } + + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { + // Skip permissions that do not have supported comparison types. + continue; + } + + if (!in_array(gettype($value), ['string', 'boolean', 'integer', 'double', 'NULL'], true)) { + // Skip permissions that do not have supported value types. + continue; + } + + if ('like' === $comparison && null === $value) { + // Skip permissions that do not have a supported values against certain comparisons. + continue; + } + + $parts = explode('.', $property); + $property = array_shift($parts); + + if (!property_exists($resourceClass, $property)) { + // Skip permissions that do not specify an existing property on the entity. + continue; + } + + $field = $this->getField($resourceClass, $property); + + if ('translation.scalar' === $field) { + if (count($parts) !== 1) { + // Skip permissions that do not specify a language and a json path. + continue; + } + + $locale = array_shift($parts); + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass, $locale, $i); + $i++; + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull($translationAlias . '.' . $property); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull($translationAlias . '.' . $property); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}($translationAlias . '.' . $property, ':ds_security_property_' . $i); + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } + } + } else if ('translation.json' === $field) { + if (count($parts) !== 2) { + // Skip permissions that do not specify a language and a json path. + continue; + } + + $locale = array_shift($parts); + $path = implode('.', $parts); + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass, $locale, $i); + $i++; + $value = $this->typeCast($value); + + if (false !== strpos($path, '.')) { + $operand = 'JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'; + } else { + $operand = 'JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'; + } + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull($operand); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull($operand); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}($operand, ':ds_security_property_' . $i); + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } + } + } else if ('json' === $field) { + if (count($parts) !== 1) { + // Skip permissions that do not specify json path. + continue; + } + + $path = implode('.', $parts); + $value = $this->typeCast($value); + + if (false !== strpos($path, '.')) { + $operand = 'JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'; + } else { + $operand = 'JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'; + } + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull($operand); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull($operand); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}($operand, ':ds_security_property_' . $i); + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } + } + } else if ('scalar' === $field) { + if (count($parts) !== 0) { + // Skip permissions that do not specify an existing property on the entity. + continue; + } + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull($rootAlias . '.' . $property); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull($rootAlias . '.' . $property); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}($rootAlias . '.' . $property, ':ds_security_property_' . $i); + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } + } + } + + $i++; + + break; + + default: + // Skip permissions with unknown scopes. In theory, this case should never + // be selected unless there are data integrity issues. + // @todo Add notice logs continue; - } + } + } - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_uuid_'.$i); - $parameters['ds_security_uuid_'.$i] = $permission->getScope()->getEntityUuid(); - $i++; + if ($subWheres) { + $wheres[] = call_user_func_array([$queryBuilder->expr(), $operator . 'X'], $subWheres); + } + } - break; + if (!$wheres) { + throw new NoPermissionsException; + } - case 'identity': - if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "identity" if the entity is not identitiable. - continue; - } - - if (in_array($resourceClass, [ - 'App\\Entity\\Anonymous', - 'App\\Entity\\Individual', - 'App\\Entity\\Organization', - 'App\\Entity\\Staff' - ])) { - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_identity_'.$i); - $parameters['ds_security_identity_'.$i] = $permission->getScope()->getEntity(); - } else if (in_array($resourceClass, [ - 'App\\Entity\\AnonymousPersona', - 'App\\Entity\\IndividualPersona', - 'App\\Entity\\OrganizationPersona', - 'App\\Entity\\StaffPersona' - ])) { - $identity = substr($resourceClass, 0, -7); - $alias = strtolower(substr($resourceClass, 17, -7)); - $subQueryBuilder = new QueryBuilder($queryBuilder->getEntityManager()); - $conditions[] = $queryBuilder->expr()->in( - $rootAlias.'.'.$alias, - $subQueryBuilder - ->select($alias) - ->from($identity, $alias) - ->where($alias.'.uuid = :ds_security_identity_uuid_'.$i) - ->getDQL() - ); - $parameters['ds_security_identity_uuid_'.$i] = $permission->getScope()->getEntityUuid(); - } else { - $conditions[] = $queryBuilder->expr()->andX( - $queryBuilder->expr()->eq($rootAlias.'.identity', ':ds_security_identity_'.$i), - $queryBuilder->expr()->eq($rootAlias.'.identityUuid', ':ds_security_identity_uuid_'.$i) - ); - $parameters['ds_security_identity_'.$i] = $permission->getScope()->getEntity(); - $parameters['ds_security_identity_uuid_'.$i] = $permission->getScope()->getEntityUuid(); - } - - $i++; - - break; - - case 'owner': - if (!in_array(Ownable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "owner" if the entity is not ownable. - continue; - } - - if (null === $permission->getScope()->getEntityUuid()) { - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.owner', ':ds_security_owner_'.$i); - $parameters['ds_security_owner_'.$i] = $permission->getScope()->getEntity(); - } else { - $conditions[] = $queryBuilder->expr()->andX( - $queryBuilder->expr()->eq($rootAlias.'.owner', ':ds_security_owner_'.$i), - $queryBuilder->expr()->eq($rootAlias.'.ownerUuid', ':ds_security_owner_uuid_'.$i) - ); - $parameters['ds_security_owner_'.$i] = $permission->getScope()->getEntity(); - $parameters['ds_security_owner_uuid_'.$i] = $permission->getScope()->getEntityUuid(); - } - - $i++; - - break; - - case 'session': - if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "session" if the entity is not identitiable. - continue; - } - - // @todo Refactor this exception handling at the entity level with metadata, the core should not know about these details. - if (in_array($resourceClass, [ - 'App\\Entity\\Anonymous', - 'App\\Entity\\Individual', - 'App\\Entity\\Organization', - 'App\\Entity\\Staff' - ])) { - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_identity_uuid_'.$i); - $parameters['ds_security_identity_uuid_'.$i] = $user->getIdentity()->getUuid(); - } else if (in_array($resourceClass, [ - 'App\\Entity\\AnonymousPersona', - 'App\\Entity\\IndividualPersona', - 'App\\Entity\\OrganizationPersona', - 'App\\Entity\\StaffPersona' - ])) { - $identity = substr($resourceClass, 0, -7); - $alias = strtolower(substr($resourceClass, 17, -7)); - $subQueryBuilder = new QueryBuilder($queryBuilder->getEntityManager()); - $conditions[] = $queryBuilder->expr()->in( - $rootAlias.'.'.$alias, - $subQueryBuilder - ->select($alias) - ->from($identity, $alias) - ->where($alias.'.uuid = :ds_security_identity_uuid_'.$i) - ->getDQL() - ); - $parameters['ds_security_identity_uuid_'.$i] = $user->getIdentity()->getUuid(); - } else { - $conditions[] = $queryBuilder->expr()->andX( - $queryBuilder->expr()->eq($rootAlias.'.identity', ':ds_security_identity_'.$i), - $queryBuilder->expr()->eq($rootAlias.'.identityUuid', ':ds_security_identity_uuid_'.$i) - ); - $parameters['ds_security_identity_'.$i] = $user->getIdentity()->getType(); - $parameters['ds_security_identity_uuid_'.$i] = $user->getIdentity()->getUuid(); - } - - $i++; - - break; + $queryBuilder->andWhere(call_user_func_array([$queryBuilder->expr(), 'orX'], $wheres)); + + foreach ($parameters as $key => $value) { + $queryBuilder->setParameter($key, $value); + } + } + + /** + * Determine what type of field the resource class property is. + * + * @param $resourceClass + * @param $property + * @return string + * @throws + */ + private function getField($resourceClass, $property): ?string + { + $manager = $this->accessService->getManager(); + $reflection = new ReflectionClass($resourceClass); + $reflectionProperty = $reflection->getProperty($property); + $translatable = in_array(Translatable::class, class_implements($resourceClass)); + $annotation = $this->annotationReader->getPropertyAnnotation($reflectionProperty, Translate::class); + + if ($translatable && $annotation) { + $translationClass = call_user_func($resourceClass . '::getTranslationEntityClass'); + $field = $this->getField($translationClass, $property); + + switch ($field) { + case null: + return null; + + case 'json': + return 'translation.json'; default: - // Skip permissions with unknown scopes. In theory, this case should never - // be selected unless there are data integrity issues. - // @todo Add notice logs - continue; + return 'translation.scalar'; } } - if (!$conditions) { - throw new NoPermissionsException; + $meta = $manager->getClassMetadata($resourceClass); + + if (!$meta->hasField($property)) { + return null; } - $queryBuilder->andWhere(call_user_func_array([$queryBuilder->expr(), 'orX'], $conditions)); + if ('json_array' === $meta->getFieldMapping($property)['type']) { + return 'json'; + } - foreach ($parameters as $key => $value) { - $queryBuilder->setParameter($key, $value); + return 'scalar'; + } + + /** + * Add a translation join entry, if not already present + * + * @param QueryBuilder $queryBuilder + * @param string $resourceClass + * @param string $locale + * @param integer $i + * @return string + */ + private function addJoinTranslation(QueryBuilder $queryBuilder, string $resourceClass, string $locale, int $i): string + { + $rootAlias = $queryBuilder->getRootAliases()[0]; + $translationAlias = $rootAlias . '_t_' . $i; + $parts = $queryBuilder->getDQLParts()['join']; + + foreach ($parts as $joins) { + foreach ($joins as $join) { + if ($translationAlias === $join->getAlias()) { + return $translationAlias; + } + } + } + + $queryBuilder->innerJoin($rootAlias . '.translations', $translationAlias/*, 'WITH', $translationAlias . '.locale = :ds_security_locale'*/); + $queryBuilder->andWhere($translationAlias . '.locale = :ds_security_translation_' . $i); + $queryBuilder->setParameter('ds_security_translation_' . $i, $locale); + + return $translationAlias; + } + + /** + * Type cast value for database JSON_GET_TEXT + * + * @param mixed $value + * @return mixed + */ + private function typeCast($value) + { + if ('string' === gettype($value)) { + // Nothing to do. + } else if ('boolean' === gettype($value)) { + $value = $value ? 'true' : 'false'; + } else if ('integer' === gettype($value)) { + $value = (string) $value; + } else if ('double' === gettype($value)) { + $value = (string) $value; + } else if ('NULL' === gettype($value)) { + // Nothing to do. } + + return $value; } } diff --git a/src/Acl/Entity/Access.php b/src/Acl/Entity/Access.php index 7077a7e..2b07fc9 100644 --- a/src/Acl/Entity/Access.php +++ b/src/Acl/Entity/Access.php @@ -28,10 +28,10 @@ * @ApiResource( * attributes={ * "normalization_context"={ - * "groups"={"access_output", "permission_output", "scope_output"} + * "groups"={"access_output", "permission_output"} * }, * "denormalization_context"={ - * "groups"={"access_input", "permission_input", "scope_input"} + * "groups"={"access_input", "permission_input"} * }, * "filters"={ * "ds_acl.access.search", @@ -80,8 +80,9 @@ class Access implements Identifiable, Uuidentifiable, Ownable, Assignable, Versi /** * @var \DateTime - * @ApiProperty(writable=false) - * @Serializer\Groups({"access_output"}) + * @ApiProperty + * @Serializer\Groups({"access_output", "access_input"}) + * @Assert\DateTime */ protected $createdAt; diff --git a/src/Acl/Entity/Attribute/Accessor/Entity.php b/src/Acl/Entity/Attribute/Accessor/Entity.php deleted file mode 100644 index 8779ca7..0000000 --- a/src/Acl/Entity/Attribute/Accessor/Entity.php +++ /dev/null @@ -1,43 +0,0 @@ -entity = $entity; - - return $this; - } - - /** - * Get entity - * - * @return string - */ - public function getEntity(): ?string - { - return $this->entity; - } -} diff --git a/src/Acl/Entity/Attribute/Accessor/EntityUuid.php b/src/Acl/Entity/Attribute/Accessor/EntityUuid.php deleted file mode 100644 index ff9b915..0000000 --- a/src/Acl/Entity/Attribute/Accessor/EntityUuid.php +++ /dev/null @@ -1,43 +0,0 @@ -entityUuid = $entityUuid; - - return $this; - } - - /** - * Get entity uuid - * - * @return string - */ - public function getEntityUuid(): ?string - { - return $this->entityUuid; - } -} diff --git a/src/Acl/Entity/Attribute/Accessor/Scope.php b/src/Acl/Entity/Attribute/Accessor/Scope.php index feca069..51107f1 100644 --- a/src/Acl/Entity/Attribute/Accessor/Scope.php +++ b/src/Acl/Entity/Attribute/Accessor/Scope.php @@ -2,7 +2,7 @@ namespace Ds\Component\Acl\Entity\Attribute\Accessor; -use Ds\Component\Acl\Entity\Scope as ScopeEntity; +use LogicException; /** * Trait Scope @@ -14,10 +14,11 @@ trait Scope /** * Set scope * - * @param \Ds\Component\Acl\Entity\Scope $scope + * @param array $scope * @return object + * @throws */ - public function setScope(?ScopeEntity $scope) + public function setScope(?array $scope) { $this->scope = $scope; @@ -27,10 +28,52 @@ public function setScope(?ScopeEntity $scope) /** * Get scope * - * @return \Ds\Component\Acl\Entity\Scope + * @return array */ - public function getScope(): ?ScopeEntity + public function getScope(): ?array { return $this->scope; } + + /** + * Get scope operator + * + * @return string + */ + public function getScopeOperator(): ?string + { + $operator = 'and'; + + if (isset($this->scope['operator'])) { + if (!in_array($this->scope['operator'], ['and', 'or'], true)) { + throw new LogicException('Permission scope operator is not valid.'); + } + + $operator = $this->scope['operator']; + } + + return $operator; + } + + /** + * Get scope conditions + * + * @return array + */ + public function getScopeConditions(): array + { + $conditions = []; + + if (isset($this->scope['conditions'])) { + if (!is_array($this->scope['conditions'])) { + throw new LogicException('Permission scope consitions is not valid.'); + } + + $conditions = $this->scope['conditions']; + } else if ($this->scope) { + $conditions = [$this->scope]; + } + + return $conditions; + } } diff --git a/src/Acl/Entity/Attribute/Accessor/Type.php b/src/Acl/Entity/Attribute/Accessor/Type.php deleted file mode 100644 index 5218d53..0000000 --- a/src/Acl/Entity/Attribute/Accessor/Type.php +++ /dev/null @@ -1,64 +0,0 @@ -getTypes() && !in_array($type, $this->getTypes(), true)) { - throw new DomainException('Type "'.$type.'" does not exist.'); - } - - $this->type = $type; - - return $this; - } - - /** - * Get type - * - * @return string - */ - public function getType(): ?string - { - return $this->type; - } - - /** - * Get types - * - * @return array - */ - public function getTypes(): array - { - static $types; - - if (null === $types) { - $types = []; - $class = new ReflectionClass($this); - - foreach ($class->getConstants() as $constant => $value) { - if ('TYPE_' === substr($constant, 0, 5)) { - $types[] = $value; - } - } - } - - return $types; - } -} diff --git a/src/Acl/Entity/Permission.php b/src/Acl/Entity/Permission.php index ab4a814..bdffe07 100644 --- a/src/Acl/Entity/Permission.php +++ b/src/Acl/Entity/Permission.php @@ -24,12 +24,12 @@ class Permission implements Identifiable, Tenantable { use Accessor\Id; - use EntityAccessor\Scope; use EntityAccessor\Access; use Accessor\Key; use Accessor\Type; use Accessor\Value; use Accessor\Attributes; + use EntityAccessor\Scope; use TenantAccessor\Tenant; /** @@ -48,13 +48,6 @@ class Permission implements Identifiable, Tenantable */ private $access; - /** - * @var string - * @Serializer\Groups({"permission_output", "permission_input"}) - * @ORM\Embedded(class="Scope") - */ - private $scope; - /** * @var string * @Serializer\Groups({"permission_output", "permission_input"}) @@ -86,6 +79,13 @@ class Permission implements Identifiable, Tenantable */ private $attributes; + /** + * @var string + * @Serializer\Groups({"permission_output", "permission_input"}) + * @ORM\Column(name="scope", type="json_array") + */ + private $scope; + /** * @var string * @ORM\Column(name="tenant", type="guid") diff --git a/src/Acl/Entity/Scope.php b/src/Acl/Entity/Scope.php deleted file mode 100644 index 8e54b8d..0000000 --- a/src/Acl/Entity/Scope.php +++ /dev/null @@ -1,52 +0,0 @@ -setResponse($response); + $event->allowCustomResponseCode(); } } diff --git a/src/Acl/Fixture/Access.php b/src/Acl/Fixture/Access.php index 2bbdaac..d3b900a 100644 --- a/src/Acl/Fixture/Access.php +++ b/src/Acl/Fixture/Access.php @@ -2,6 +2,7 @@ namespace Ds\Component\Acl\Fixture; +use DateTime; use Doctrine\Common\Persistence\ObjectManager; use Ds\Component\Acl\Entity\Access as AccessEntity; use Ds\Component\Database\Fixture\Yaml; @@ -36,6 +37,13 @@ public function load(ObjectManager $manager) ->setAssignee($object->assignee) ->setAssigneeUuid($object->assignee_uuid) ->setTenant($object->tenant); + + if (null !== $object->created_at) { + $date = new DateTime; + $date->setTimestamp($object->created_at); + $access->setCreatedAt($date); + } + $manager->persist($access); } diff --git a/src/Acl/Fixture/Permission.php b/src/Acl/Fixture/Permission.php index 2a62645..7f59295 100644 --- a/src/Acl/Fixture/Permission.php +++ b/src/Acl/Fixture/Permission.php @@ -5,7 +5,6 @@ use Doctrine\Common\Persistence\ObjectManager; use Ds\Component\Acl\Entity\Access; use Ds\Component\Acl\Entity\Permission as PermissionEntity; -use Ds\Component\Acl\Entity\Scope; use Ds\Component\Database\Fixture\Yaml; use LogicException; @@ -42,15 +41,10 @@ public function load(ObjectManager $manager) throw new LogicException('Access "'.$object->access.'" does not exist.'); } - $scope = new Scope; - $scope - ->setType($object->scope->type ?? null) - ->setEntity($object->scope->entity ?? null) - ->setEntityUuid($object->scope->entity_uuid ?? null); $permission = new PermissionEntity; $permission ->setAccess($access) - ->setScope($scope) + ->setScope((array) $object->scope) ->setKey($key) ->setAttributes($object->attributes) ->setTenant($object->tenant); diff --git a/src/Acl/Migration/Version0_19_0.php b/src/Acl/Migration/Version0_19_0.php new file mode 100644 index 0000000..0acc00a --- /dev/null +++ b/src/Acl/Migration/Version0_19_0.php @@ -0,0 +1,71 @@ +platform->getName()) { + case 'postgresql': + $this->addSql('ALTER TABLE ds_access_permission ADD scope JSON DEFAULT \'{}\''); + $this->addSql(' + UPDATE + ds_access_permission + SET + scope = CONCAT( + \'{\', + \'"type": "\', scope_type, \'"\', + CASE WHEN scope_entity IS NOT NULL THEN CONCAT(\', "entity": "\', scope_entity, \'"\') ELSE \'\' END, + CASE WHEN scope_entity_uuid IS NOT NULL THEN CONCAT(\', "entity_uuid": "\', scope_entity_uuid, \'"\') ELSE \'\' END, + \'}\')::jsonb + '); + $this->addSql('ALTER TABLE ds_access_permission ALTER scope DROP DEFAULT'); + $this->addSql('ALTER TABLE ds_access_permission ALTER scope SET NOT NULL'); + $this->addSql('COMMENT ON COLUMN ds_access_permission.scope IS \'(DC2Type:json_array)\''); + $this->addSql('ALTER TABLE ds_access_permission DROP COLUMN scope_type'); + $this->addSql('ALTER TABLE ds_access_permission DROP COLUMN scope_entity'); + $this->addSql('ALTER TABLE ds_access_permission DROP COLUMN scope_entity_uuid'); + break; + + default: + $this->abortIf(true,'Migration cannot be executed on "'.$this->platform->getName().'".'); + break; + } + } + + /** + * Down migration + * + * @param \Doctrine\DBAL\Schema\Schema $schema + */ + public function down(Schema $schema) + { + switch ($this->platform->getName()) { + case 'postgresql': + $this->addSql('ALTER TABLE ds_access_permission ADD scope_type VARCHAR(32) DEFAULT NULL'); + $this->addSql('ALTER TABLE ds_access_permission ADD scope_entity VARCHAR(32) DEFAULT NULL'); + $this->addSql('ALTER TABLE ds_access_permission ADD scope_entity_uuid VARCHAR(36) DEFAULT NULL'); + $this->addSql('UPDATE ds_access_permission SET scope_type = scope ->> \'type\', scope_entity = scope ->> \'entity\', scope_entity_uuid = scope ->> \'entity_uuid\''); + $this->addSql('ALTER TABLE ds_access_permission DROP COLUMN scope'); + break; + + default: + $this->abortIf(true,'Migration cannot be executed on "'.$this->platform->getName().'".'); + break; + } + } +} diff --git a/src/Acl/Service/AccessService.php b/src/Acl/Service/AccessService.php index c843167..e619918 100644 --- a/src/Acl/Service/AccessService.php +++ b/src/Acl/Service/AccessService.php @@ -49,7 +49,7 @@ public function getPermissions(User $user, bool $cache = false) $permissions = new ArrayCollection; - // Generic identity permissions + // Permissions assigned to the identity type. $accesses = $this->repository->findBy([ 'assignee' => $user->getIdentity()->getType(), 'assigneeUuid' => null @@ -61,7 +61,7 @@ public function getPermissions(User $user, bool $cache = false) } } - // Specific identity permissions + // Permissions assigned to the identity uuid. $accesses = $this->repository->findBy([ 'assignee' => $user->getIdentity()->getType(), 'assigneeUuid' => $user->getIdentity()->getUuid() @@ -73,7 +73,7 @@ public function getPermissions(User $user, bool $cache = false) } } - // Roles permissions + // Permissions assigned to a role. $roles = $user->getIdentity()->getRoles(); $accesses = $this->repository->findBy([ @@ -87,15 +87,53 @@ public function getPermissions(User $user, bool $cache = false) foreach ($access->getPermissions() as $permission) { $scope = $permission->getScope(); - if ('*' === $scope->getEntityUuid()) { - if ('owner' === $scope->getType() && 'BusinessUnit' === $scope->getEntity()) { - foreach ($roles[$role] as $businessUnit) { + if (array_key_exists('conditions', $scope)) { + $dynamic = false; + + foreach ($scope['conditions'] as $condition) { + if (array_key_exists('entity_uuid', $condition)) { + if ('*' === $condition['entity_uuid']) { + $dynamic = true; + } + } + } + + if ($dynamic) { + foreach ($roles[$role] as $entityUuid) { $clone = clone $permission; - $clone->getScope()->setEntityUuid($businessUnit); + $cloneScope = $clone->getScope(); + + foreach ($cloneScope['conditions'] as $key => $condition) { + if (array_key_exists('entity_uuid', $condition)) { + if ('*' === $condition['entity_uuid']) { + $cloneScope['conditions'][$key]['entity_uuid'] = $entityUuid; + } + } + } + + $clone->setScope($cloneScope); $permissions->add($clone); } } else { + $permissions->add($permission); + } + } else if (array_key_exists('entity_uuid', $scope)) { + $dynamic = false; + if ('*' === $scope['entity_uuid']) { + $dynamic = true; + } + + if ($dynamic) { + foreach ($roles[$role] as $entityUuid) { + $clone = clone $permission; + $cloneScope = $clone->getScope(); + $cloneScope['entity_uuid'] = $entityUuid; + $clone->setScope($cloneScope); + $permissions->add($clone); + } + } else { + $permissions->add($permission); } } else { $permissions->add($permission); diff --git a/src/Acl/Tenant/Loader/Acl.php b/src/Acl/Tenant/Loader/Acl.php index 6bc7e97..27c73e1 100644 --- a/src/Acl/Tenant/Loader/Acl.php +++ b/src/Acl/Tenant/Loader/Acl.php @@ -2,7 +2,6 @@ namespace Ds\Component\Acl\Tenant\Loader; -use Ds\Component\Acl\Entity\Scope; use Ds\Component\Database\Util\Objects; use Ds\Component\Tenant\Entity\Tenant; @@ -47,14 +46,9 @@ public function load(Tenant $tenant) ->setTenant($object->tenant); foreach ($object->permissions as $subObject) { - $scope = new Scope; - $scope - ->setType($subObject->scope->type ?? null) - ->setEntity($subObject->scope->entity ?? null) - ->setEntityUuid($subObject->scope->entity_uuid ?? null); $permission = $this->permissionService->createInstance(); $permission - ->setScope($scope) + ->setScope((array) $subObject->scope) ->setKey($subObject->key) ->setAttributes($subObject->attributes) ->setTenant($object->tenant); diff --git a/src/Acl/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index d7ef048..970f83c 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -2,6 +2,7 @@ namespace Ds\Component\Acl\Voter; +use Doctrine\Common\Annotations\Reader; use Ds\Component\Acl\Collection\EntityCollection; use Ds\Component\Acl\Model\Permission; use Ds\Component\Acl\Service\AccessService; @@ -9,6 +10,10 @@ use Ds\Component\Model\Type\Ownable; use Ds\Component\Model\Type\Uuidentifiable; use Ds\Component\Security\Model\User; +use Ds\Component\Translation\Model\Annotation\Translate; +use Ds\Component\Translation\Model\Type\Translatable; +use ReflectionClass; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -33,16 +38,29 @@ final class EntityVoter extends Voter */ private $entityCollection; + /** + * @var \Doctrine\Common\Annotations\Reader + */ + private $annotationReader; + + /** + * @var \Symfony\Component\PropertyAccess\PropertyAccessor + */ + private $accessor; + /** * Constructor * * @param \Ds\Component\Acl\Service\AccessService $accessService * @param \Ds\Component\Acl\Collection\EntityCollection $entityCollection + * @param \Doctrine\Common\Annotations\Reader $annotationReader */ - public function __construct(AccessService $accessService, EntityCollection $entityCollection) + public function __construct(AccessService $accessService, EntityCollection $entityCollection, Reader $annotationReader) { $this->accessService = $accessService; $this->entityCollection = $entityCollection; + $this->annotationReader = $annotationReader; + $this->accessor = PropertyAccess::createPropertyAccessor(); } /** @@ -92,98 +110,258 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - switch ($permission->getScope()->getType()) { - case 'generic': - // Nothing to specifically validate. - break; + if (!in_array($attribute, $permission->getAttributes(), true)) { + // Skip permissions that do not contain the required attribute. + continue; + } - case 'object': - if (!$subject instanceof Uuidentifiable) { - // Skip permissions with scope "object" if the subject entity is not uuidentitiable. - continue; - } + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $results = []; - if ($permission->getScope()->getEntityUuid() !== $subject->getUuid()) { - // Skip permissions that do not match the subject entity uuid. - continue; - } + foreach ($conditions as $condition) { + $result = null; + $type = isset($condition['type']) ? $condition['type'] : null; - break; + switch ($type) { + case 'generic': + // Nothing to specifically validate. + $result = true; + break; - case 'identity': - if (!$subject instanceof Identitiable) { - // Skip permissions with scope "identity" if the subject entity is not identitiable. - continue; - } + case 'object': + if (!$subject instanceof Uuidentifiable) { + // Skip permissions with scope "object" if the subject entity is not uuidentitiable. + continue; + } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject->getIdentity()) { - // Skip permissions that do not match the subject entity identity. + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. continue; } - } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + $result = true; + + if ($condition['entity_uuid'] !== $subject->getUuid()) { + $result = false; + } + + break; + + case 'identity': + if (!$subject instanceof Identitiable) { + // Skip permissions with scope "identity" if the subject entity is not identitiable. continue; } - } - break; + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - case 'owner': - if (!$subject instanceof Ownable) { - // Skip permissions with scope "owner" if the subject entity is not ownable. - continue; - } + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } + + $result = true; - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject->getOwner()) { - // Skip permissions that do not match the subject entity owner. + if ($condition['entity'] !== $subject->getIdentity()) { + $result = false; + } + + if ($condition['entity_uuid'] !== $subject->getIdentityUuid()) { + $result = false; + } + + break; + + case 'owner': + if (!$subject instanceof Ownable) { + // Skip permissions with scope "owner" if the subject entity is not ownable. continue; } - } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject->getOwnerUuid()) { - // Skip permissions that do not match the subject entity owner uuid. + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. continue; } - } - break; + $result = true; - case 'session': - if (!$subject instanceof Identitiable) { - // Skip permissions with scope "session" if the subject entity is not identitiable. - continue; - } + if ($condition['entity'] !== $subject->getOwner()) { + $result = false; + } - if ($user->getIdentity()->getType() !== $subject->getIdentity()) { - // Skip permissions that do not match the subject entity identity. - continue; - } + if (isset($condition['entity_uuid'])) { + if ($condition['entity_uuid'] !== $subject->getOwnerUuid()) { + $result = false; + } + } + + break; + + case 'session': + if (!$subject instanceof Identitiable) { + // Skip permissions with scope "session" if the subject entity is not identitiable. + continue; + } + + $result = true; + + if ($user->getIdentity()->getType() !== $subject->getIdentity()) { + $result = false; + } + + if ($user->getIdentity()->getUuid() !== $subject->getIdentityUuid()) { + $result = false; + } + + break; + + case 'property': + $property = isset($condition['property']) ? $condition['property'] : null; + $value = isset($condition['value']) ? $condition['value'] : null; + $comparison = isset($condition['comparison']) ? $condition['comparison'] : 'eq'; + + if (null === $property) { + // Skip permissions that do not define a property. + continue; + } - if ($user->getIdentity()->getUuid() !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { + // Skip permissions that do not have supported comparison types. + continue; + } + + if (!in_array(gettype($value), ['string', 'boolean', 'integer', 'double', 'NULL'], true)) { + // Skip permissions that do not have supported value types. + continue; + } + + if ('like' === $comparison && null === $value) { + // Skip permissions that do not have a supported values against certain comparisons. + continue; + } + + $parts = explode('.', $property); + $property = array_shift($parts); + $path = str_replace('\'', '', implode('.', $parts)); + + if (!property_exists($subject, $property)) { + // Skip permissions that contains an unreadable property. + continue; + } + + $field = $this->getField(get_class($subject), $property); + $result = true; + + if ('' !== $path) { + if ('translation.scalar' === $field) { + $property .= '[' . $path . ']'; + } else if ('json' === $field || 'translation.json' === $field) { + $property .= '[' . str_replace('.', '][', $path) . ']'; + } else { + $property .= '.' . $path; + } + } + + if (!$this->accessor->isReadable($subject, $property)) { + $result = false; + } + + if ('eq' === $comparison) { + if ($this->accessor->getValue($subject, $property) !== $value) { + $result = false; + } + } else if ('neq' === $comparison) { + if ($this->accessor->getValue($subject, $property) === $value) { + $result = false; + } + } else if ('like' === $comparison) { + $needle = (string) $value; + $haystack = (string) $this->accessor->getValue($subject, $property); + + if (false === strpos($haystack, $needle)) { + $result = false; + } + } + + break; + + default: + // Skip permissions with unknown scopes. In theory, this case should never + // be selected unless there are data integrity issues. + // @todo Add notice logs continue; - } + } - break; + if (null !== $result) { + $results[] = $result; + } + } - default: - // Skip permissions with unknown scopes. In theory, this case should never - // be selected unless there are data integrity issues. - // @todo Add notice logs - continue; + if (!$results) { + // Skip permissions that yields no results. + continue; } - if (in_array($attribute, $permission->getAttributes(), true)) { + if ('and' === $operator && !in_array(false, $results, true)) { + // All results must be true. + return true; + } + + if ('or' === $operator && in_array(true, $results, true)) { + // At least one result must be true. return true; } } return false; } + + /** + * Determine what type of field the resource class property is. + * + * @param $resourceClass + * @param $property + * @return string + * @throws + */ + private function getField($resourceClass, $property): ?string + { + $manager = $this->accessService->getManager(); + $reflection = new ReflectionClass($resourceClass); + $reflectionProperty = $reflection->getProperty($property); + $translatable = in_array(Translatable::class, class_implements($resourceClass)); + $annotation = $this->annotationReader->getPropertyAnnotation($reflectionProperty, Translate::class); + + if ($translatable && $annotation) { + $translationClass = call_user_func($resourceClass . '::getTranslationEntityClass'); + $field = $this->getField($translationClass, $property); + + switch ($field) { + case null: + return null; + + case 'json': + return 'translation.json'; + + default: + return 'translation.scalar'; + } + } + + $meta = $manager->getClassMetadata($resourceClass); + + if (!$meta->hasField($property)) { + return null; + } + + if ('json_array' === $meta->getFieldMapping($property)['type']) { + return 'json'; + } + + return 'scalar'; + } } diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 5b02714..01a08d2 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -2,6 +2,7 @@ namespace Ds\Component\Acl\Voter; +use Doctrine\Common\Annotations\Reader; use Ds\Component\Acl\Collection\EntityCollection; use Ds\Component\Acl\Model\Permission; use Ds\Component\Acl\Service\AccessService; @@ -9,6 +10,10 @@ use Ds\Component\Model\Type\Ownable; use Ds\Component\Model\Type\Uuidentifiable; use Ds\Component\Security\Model\User; +use Ds\Component\Translation\Model\Annotation\Translate; +use Ds\Component\Translation\Model\Type\Translatable; +use ReflectionClass; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -33,16 +38,29 @@ final class PropertyVoter extends Voter */ private $entityCollection; + /** + * @var \Doctrine\Common\Annotations\Reader + */ + private $annotationReader; + + /** + * @var \Symfony\Component\PropertyAccess\PropertyAccessor + */ + private $accessor; + /** * Constructor * * @param \Ds\Component\Acl\Service\AccessService $accessService * @param \Ds\Component\Acl\Collection\EntityCollection $entityCollection + * @param \Doctrine\Common\Annotations\Reader $annotationReader */ - public function __construct(AccessService $accessService, EntityCollection $entityCollection) + public function __construct(AccessService $accessService, EntityCollection $entityCollection, Reader $annotationReader) { $this->accessService = $accessService; $this->entityCollection = $entityCollection; + $this->annotationReader = $annotationReader; + $this->accessor = PropertyAccess::createPropertyAccessor(); } /** @@ -112,98 +130,258 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - switch ($permission->getScope()->getType()) { - case 'generic': - // Nothing to specifically validate. - break; + if (!in_array($attribute, $permission->getAttributes(), true)) { + // Skip permissions that do not contain the required attribute. + continue; + } - case 'object': - if (!$subject instanceof Uuidentifiable) { - // Skip permissions with scope "object" if the subject entity is not uuidentitiable. - continue; - } + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $results = []; - if ($permission->getScope()->getEntityUuid() !== $subject->getUuid()) { - // Skip permissions that do not match the subject entity uuid. - continue; - } + foreach ($conditions as $condition) { + $result = null; + $type = isset($condition['type']) ? $condition['type'] : null; - break; + switch ($type) { + case 'generic': + // Nothing to specifically validate. + $result = true; + break; - case 'identity': - if (!$subject[0] instanceof Identitiable) { - // Skip permissions with scope "identity" if the subject entity is not identitiable. - continue; - } + case 'object': + if (!$subject[0] instanceof Uuidentifiable) { + // Skip permissions with scope "object" if the subject entity is not uuidentitiable. + continue; + } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject[0]->getIdentity()) { - // Skip permissions that do not match the subject entity identity. + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. continue; } - } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject[0]->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + $result = true; + + if ($condition['entity_uuid'] !== $subject[0]->getUuid()) { + $result = false; + } + + break; + + case 'identity': + if (!$subject[0] instanceof Identitiable) { + // Skip permissions with scope "identity" if the subject entity is not identitiable. continue; } - } - break; + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - case 'owner': - if (!$subject[0] instanceof Ownable) { - // Skip permissions with scope "owner" if the subject entity is not ownable. - continue; - } + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } + + $result = true; - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject[0]->getOwner()) { - // Skip permissions that do not match the subject entity owner. + if ($condition['entity'] !== $subject[0]->getIdentity()) { + $result = false; + } + + if ($condition['entity_uuid'] !== $subject[0]->getIdentityUuid()) { + $result = false; + } + + break; + + case 'owner': + if (!$subject[0] instanceof Ownable) { + // Skip permissions with scope "owner" if the subject entity is not ownable. continue; } - } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject[0]->getOwnerUuid()) { - // Skip permissions that do not match the subject entity owner uuid. + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. continue; } - } - break; + $result = true; - case 'session': - if (!$subject instanceof Identitiable) { - // Skip permissions with scope "session" if the subject entity is not identitiable. - continue; - } + if ($condition['entity'] !== $subject[0]->getOwner()) { + $result = false; + } - if ($user->getIdentity()->getType() !== $subject->getIdentity()) { - // Skip permissions that do not match the subject entity identity. - continue; - } + if (isset($condition['entity_uuid'])) { + if ($condition['entity_uuid'] !== $subject[0]->getOwnerUuid()) { + $result = false; + } + } + + break; + + case 'session': + if (!$subject[0] instanceof Identitiable) { + // Skip permissions with scope "session" if the subject entity is not identitiable. + continue; + } + + $result = true; + + if ($user->getIdentity()->getType() !== $subject[0]->getIdentity()) { + $result = false; + } + + if ($user->getIdentity()->getUuid() !== $subject[0]->getIdentityUuid()) { + $result = false; + } + + break; + + case 'property': + $property = isset($condition['property']) ? $condition['property'] : null; + $value = isset($condition['value']) ? $condition['value'] : null; + $comparison = isset($condition['comparison']) ? $condition['comparison'] : 'eq'; + + if (null === $property) { + // Skip permissions that do not define a property. + continue; + } - if ($user->getIdentity()->getUuid() !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { + // Skip permissions that do not have supported comparison types. + continue; + } + + if (!in_array(gettype($value), ['string', 'boolean', 'integer', 'double', 'NULL'], true)) { + // Skip permissions that do not have supported value types. + continue; + } + + if ('like' === $comparison && null === $value) { + // Skip permissions that do not have a supported values against certain comparisons. + continue; + } + + $parts = explode('.', $property); + $property = array_shift($parts); + $path = str_replace('\'', '', implode('.', $parts)); + + if (!property_exists($subject[0], $property)) { + // Skip permissions that contains an unreadable property. + continue; + } + + $field = $this->getField(get_class($subject[0]), $property); + $result = true; + + if ('' !== $path) { + if ('translation.scalar' === $field) { + $property .= '[' . $path . ']'; + } else if ('json' === $field || 'translation.json' === $field) { + $property .= '[' . str_replace('.', '][', $path) . ']'; + } else { + $property .= '.' . $path; + } + } + + if (!$this->accessor->isReadable($subject[0], $property)) { + $result = false; + } + + if ('eq' === $comparison) { + if ($this->accessor->getValue($subject[0], $property) !== $value) { + $result = false; + } + } else if ('neq' === $comparison) { + if ($this->accessor->getValue($subject[0], $property) === $value) { + $result = false; + } + } else if ('like' === $comparison) { + $needle = (string) $value; + $haystack = (string) $this->accessor->getValue($subject[0], $property); + + if (false === strpos($haystack, $needle)) { + $result = false; + } + } + + break; + + default: + // Skip permissions with unknown scopes. In theory, this case should never + // be selected unless there are data integrity issues. + // @todo Add notice logs continue; - } + } - break; + if (null !== $result) { + $results[] = $result; + } + } - default: - // Skip permissions with unknown scopes. In theory, this case should never - // be selected unless there are data integrity issues. - // @todo Add notice logs - continue; + if (!$results) { + // Skip permissions that yields no results. + continue; } - if (in_array($attribute, $permission->getAttributes(), true)) { + if ('and' === $operator && !in_array(false, $results, true)) { + // All results must be true. + return true; + } + + if ('or' === $operator && in_array(true, $results, true)) { + // At least one result must be true. return true; } } return false; } + + /** + * Determine what type of field the resource class property is. + * + * @param $resourceClass + * @param $property + * @return string + * @throws + */ + private function getField($resourceClass, $property): ?string + { + $manager = $this->accessService->getManager(); + $reflection = new ReflectionClass($resourceClass); + $reflectionProperty = $reflection->getProperty($property); + $translatable = in_array(Translatable::class, class_implements($resourceClass)); + $annotation = $this->annotationReader->getPropertyAnnotation($reflectionProperty, Translate::class); + + if ($translatable && $annotation) { + $translationClass = call_user_func($resourceClass . '::getTranslationEntityClass'); + $field = $this->getField($translationClass, $property); + + switch ($field) { + case null: + return null; + + case 'json': + return 'translation.json'; + + default: + return 'translation.scalar'; + } + } + + $meta = $manager->getClassMetadata($resourceClass); + + if (!$meta->hasField($property)) { + return null; + } + + if ('json_array' === $meta->getFieldMapping($property)['type']) { + return 'json'; + } + + return 'scalar'; + } } diff --git a/src/Api/Api/Api.php b/src/Api/Api/Api.php index 25216d1..373e2a3 100644 --- a/src/Api/Api/Api.php +++ b/src/Api/Api/Api.php @@ -112,6 +112,7 @@ protected function getToken(): string 'roles' => $this->configService->get('ds_api.user.roles'), 'identity' => (object) [ 'roles' => $this->configService->get('ds_api.user.identity.roles'), + 'business_units' => [], 'type' => $this->configService->get('ds_api.user.identity.type'), 'uuid' => $this->configService->get('ds_api.user.identity.uuid') ], diff --git a/src/Api/Model/AnonymousRole.php b/src/Api/Model/AnonymousRole.php index da6b2d2..20950e6 100644 --- a/src/Api/Model/AnonymousRole.php +++ b/src/Api/Model/AnonymousRole.php @@ -20,7 +20,7 @@ final class AnonymousRole implements Model use Attribute\OwnerUuid; use ApiAttribute\Anonymous; use ApiAttribute\Role; - use ApiAttribute\BusinessUnits; + use Attribute\EntityUuids; use Attribute\Version; use Attribute\Tenant; @@ -29,7 +29,7 @@ final class AnonymousRole implements Model */ public function __construct() { - $this->businessUnits = []; + $this->entityUuids = []; $this->version = 1; } } diff --git a/src/Api/Model/Attribute/Accessor/BusinessUnit.php b/src/Api/Model/Attribute/Accessor/BusinessUnit.php new file mode 100644 index 0000000..8731177 --- /dev/null +++ b/src/Api/Model/Attribute/Accessor/BusinessUnit.php @@ -0,0 +1,36 @@ +businessUnit = $businessUnit; + + return $this; + } + + /** + * Get business unit + * + * @return \Ds\Component\Api\Model\BusinessUnit + */ + public function getBusinessUnit(): ?BusinessUnitModel + { + return $this->businessUnit; + } +} diff --git a/src/Api/Model/Attribute/BusinessUnit.php b/src/Api/Model/Attribute/BusinessUnit.php new file mode 100644 index 0000000..46887f4 --- /dev/null +++ b/src/Api/Model/Attribute/BusinessUnit.php @@ -0,0 +1,18 @@ +entityUuids = []; + $this->version = 1; + } +} diff --git a/src/Api/Model/IndividualRole.php b/src/Api/Model/IndividualRole.php index 1778b75..9264660 100644 --- a/src/Api/Model/IndividualRole.php +++ b/src/Api/Model/IndividualRole.php @@ -20,7 +20,7 @@ final class IndividualRole implements Model use Attribute\OwnerUuid; use ApiAttribute\Individual; use ApiAttribute\Role; - use ApiAttribute\BusinessUnits; + use Attribute\EntityUuids; use Attribute\Version; use Attribute\Tenant; @@ -29,7 +29,7 @@ final class IndividualRole implements Model */ public function __construct() { - $this->businessUnits = []; + $this->entityUuids = []; $this->version = 1; } } diff --git a/src/Api/Model/OrganizationRole.php b/src/Api/Model/OrganizationRole.php index 2420922..eca0f14 100644 --- a/src/Api/Model/OrganizationRole.php +++ b/src/Api/Model/OrganizationRole.php @@ -20,7 +20,7 @@ final class OrganizationRole implements Model use Attribute\OwnerUuid; use ApiAttribute\Organization; use ApiAttribute\Role; - use ApiAttribute\BusinessUnits; + use Attribute\EntityUuids; use Attribute\Version; use Attribute\Tenant; @@ -29,7 +29,7 @@ final class OrganizationRole implements Model */ public function __construct() { - $this->businessUnits = []; + $this->entityUuids = []; $this->version = 1; } } diff --git a/src/Api/Model/Permission.php b/src/Api/Model/Permission.php index 8fa5b34..8b3c9d3 100644 --- a/src/Api/Model/Permission.php +++ b/src/Api/Model/Permission.php @@ -3,7 +3,6 @@ namespace Ds\Component\Api\Model; use Ds\Component\Model\Attribute; -use Ds\Component\Api\Model\Attribute as ApiAttribute; /** * Class Permission @@ -16,7 +15,7 @@ final class Permission implements Model use Attribute\Uuid; use Attribute\CreatedAt; use Attribute\UpdatedAt; - use ApiAttribute\Scope; + use Attribute\Scope; use Attribute\Entity; use Attribute\EntityUuid; use Attribute\Key; @@ -29,5 +28,6 @@ final class Permission implements Model public function __construct() { $this->attributes = []; + $this->scope = []; } } diff --git a/src/Api/Model/Scope.php b/src/Api/Model/Scope.php deleted file mode 100644 index 1176497..0000000 --- a/src/Api/Model/Scope.php +++ /dev/null @@ -1,18 +0,0 @@ -roles = []; + $this->businessUnits = []; } } diff --git a/src/Api/Model/StaffRole.php b/src/Api/Model/StaffRole.php index b915783..db1a5c7 100644 --- a/src/Api/Model/StaffRole.php +++ b/src/Api/Model/StaffRole.php @@ -20,7 +20,7 @@ final class StaffRole implements Model use Attribute\OwnerUuid; use ApiAttribute\Staff; use ApiAttribute\Role; - use ApiAttribute\BusinessUnits; + use Attribute\EntityUuids; use Attribute\Version; use Attribute\Tenant; @@ -29,7 +29,7 @@ final class StaffRole implements Model */ public function __construct() { - $this->businessUnits = []; + $this->entityUuids = []; $this->version = 1; } } diff --git a/src/Api/Model/SystemRole.php b/src/Api/Model/SystemRole.php index 8b5cba0..20698f5 100644 --- a/src/Api/Model/SystemRole.php +++ b/src/Api/Model/SystemRole.php @@ -20,7 +20,7 @@ final class SystemRole implements Model use Attribute\OwnerUuid; use ApiAttribute\System; use ApiAttribute\Role; - use ApiAttribute\BusinessUnits; + use Attribute\EntityUuids; use Attribute\Version; use Attribute\Tenant; @@ -29,7 +29,7 @@ final class SystemRole implements Model */ public function __construct() { - $this->businessUnits = []; + $this->entityUuids = []; $this->version = 1; } } diff --git a/src/Api/Query/Attribute/BusinessUnitUuid.php b/src/Api/Query/Attribute/BusinessUnitUuid.php new file mode 100644 index 0000000..809bf41 --- /dev/null +++ b/src/Api/Query/Attribute/BusinessUnitUuid.php @@ -0,0 +1,47 @@ +businessUnitUuid = $businessUnitUuid; + $this->_businessUnitUuid = true; + + return $this; + } + + /** + * Get business unit uuid + * + * @return string + */ + public function getBusinessUnitUuid(): ?string + { + return $this->businessUnitUuid; + } + + # endregion + + /** + * @var boolean + */ + private $_businessUnitUuid; +} diff --git a/src/Api/Query/BusinessUnitRoleParameters.php b/src/Api/Query/BusinessUnitRoleParameters.php new file mode 100644 index 0000000..210226b --- /dev/null +++ b/src/Api/Query/BusinessUnitRoleParameters.php @@ -0,0 +1,16 @@ +{'set'.ucfirst($local)}($value); break; + case 'businessUnit': + $value = new BusinessUnit; + $value->setUuid(substr($object->$remote, 16)); + $model->{'set'.ucfirst($local)}($value); + break; + case 'businessUnits': $values = $object->$remote; @@ -178,15 +184,6 @@ public static function toObject(Model $model) break; - case 'scope': - $object->$remote = [ - 'type' => $value->getType(), - 'entity' => $value->getEntity(), - 'entityUuid' => $value->getEntityUuid() - ]; - - break; - default: $object->$remote = $value; } diff --git a/src/Api/Service/BusinessUnitRoleService.php b/src/Api/Service/BusinessUnitRoleService.php new file mode 100644 index 0000000..0d4dd8d --- /dev/null +++ b/src/Api/Service/BusinessUnitRoleService.php @@ -0,0 +1,78 @@ +toObject(true); + + if (array_key_exists('businessUnitUuid', $options['query'])) { + $options['query']['businessUnit.uuid'] = $options['query']['businessUnitUuid']; + unset($options['query']['businessUnitUuid']); + } + + if (array_key_exists('staffUuid', $options['query'])) { + $options['query']['businessUnit.staffs.uuid'] = $options['query']['staffUuid']; + unset($options['query']['staffUuid']); + } + } + + $objects = $this->execute('GET', static::RESOURCE_LIST, $options); + $list = []; + + foreach ($objects as $object) { + $model = static::toModel($object); + $list[] = $model; + } + + return $list; + } +} diff --git a/src/Api/Service/IndividualRoleService.php b/src/Api/Service/IndividualRoleService.php index a09babc..8f84ef3 100644 --- a/src/Api/Service/IndividualRoleService.php +++ b/src/Api/Service/IndividualRoleService.php @@ -36,7 +36,7 @@ final class IndividualRoleService implements Service 'ownerUuid', 'individual', 'role', - 'businessUnits', + 'entityUuids', 'version', 'tenant' ]; diff --git a/src/Api/Service/OrganizationRoleService.php b/src/Api/Service/OrganizationRoleService.php index 48c120d..37903f2 100644 --- a/src/Api/Service/OrganizationRoleService.php +++ b/src/Api/Service/OrganizationRoleService.php @@ -36,7 +36,7 @@ final class OrganizationRoleService implements Service 'ownerUuid', 'organization', 'role', - 'businessUnits', + 'entityUuids', 'version', 'tenant' ]; diff --git a/src/Api/Service/StaffRoleService.php b/src/Api/Service/StaffRoleService.php index c50651e..1b36959 100644 --- a/src/Api/Service/StaffRoleService.php +++ b/src/Api/Service/StaffRoleService.php @@ -36,7 +36,7 @@ final class StaffRoleService implements Service 'ownerUuid', 'staff', 'role', - 'businessUnits', + 'entityUuids', 'version', 'tenant' ]; diff --git a/src/Api/Service/StaffService.php b/src/Api/Service/StaffService.php index 284246d..d0cf31e 100644 --- a/src/Api/Service/StaffService.php +++ b/src/Api/Service/StaffService.php @@ -37,6 +37,7 @@ class StaffService implements Service 'owner', 'ownerUuid', 'roles', + 'businessUnits', 'version', 'tenant' ]; diff --git a/src/Api/Service/SystemRoleService.php b/src/Api/Service/SystemRoleService.php index e93ec37..127d61a 100644 --- a/src/Api/Service/SystemRoleService.php +++ b/src/Api/Service/SystemRoleService.php @@ -36,7 +36,7 @@ final class SystemRoleService implements Service 'ownerUuid', 'system', 'role', - 'businessUnits', + 'entityUuids', 'version', 'tenant' ]; diff --git a/src/Association/Entity/Association.php b/src/Association/Entity/Association.php index f1702d7..3cb1c23 100644 --- a/src/Association/Entity/Association.php +++ b/src/Association/Entity/Association.php @@ -57,8 +57,9 @@ abstract class Association implements Identifiable, Uuidentifiable, Associable, /** * @var \DateTime - * @ApiProperty(writable=false) - * @Serializer\Groups({"association_output"}) + * @ApiProperty + * @Serializer\Groups({"association_output", "association_input"}) + * @Assert\DateTime */ protected $createdAt; diff --git a/src/Camunda/Query/Attribute/CandidateGroups.php b/src/Camunda/Query/Attribute/CandidateGroups.php new file mode 100644 index 0000000..5f6d631 --- /dev/null +++ b/src/Camunda/Query/Attribute/CandidateGroups.php @@ -0,0 +1,49 @@ +candidateGroups = $candidateGroups; + $this->_candidateGroups = null !== $candidateGroups; + } + + return $this; + } + + /** + * Get candidate groups + * + * @return string + */ + public function getCandidateGroups(): ?array + { + return $this->candidateGroups; + } + + # endregion + + /** + * @var boolean + */ + private $_candidateGroups; +} diff --git a/src/Camunda/Query/Attribute/Unassigned.php b/src/Camunda/Query/Attribute/Unassigned.php new file mode 100644 index 0000000..b181a24 --- /dev/null +++ b/src/Camunda/Query/Attribute/Unassigned.php @@ -0,0 +1,47 @@ +unassigned = $unassigned; + $this->_unassigned = null !== $unassigned; + + return $this; + } + + /** + * Get unassigned + * + * @return boolean + */ + public function getUnassigned(): ?bool + { + return $this->unassigned; + } + + # endregion + + /** + * @var boolean + */ + private $_unassigned; +} diff --git a/src/Camunda/Query/Base.php b/src/Camunda/Query/Base.php index caf63b8..b33d87e 100644 --- a/src/Camunda/Query/Base.php +++ b/src/Camunda/Query/Base.php @@ -16,7 +16,7 @@ trait Base /** * {@inheritdoc} */ - public function toObject(bool $minimal = false) + public function toObject(bool $minimal = false, $type = 'query') { $object = new stdClass; @@ -45,11 +45,23 @@ public function toObject(bool $minimal = false) break; case 'cascade': - $object->$key = $value ? 'true' : 'false'; + case 'unassigned': + if ('query' === $type) { + $object->$key = $value ? 'true' : 'false'; + } else { + $object->$key = $value; + } + break; case 'tenantIdIn': - $object->$key = implode(',', $value); + case 'candidateGroups': + if ('query' === $type) { + $object->$key = implode(',', $value); + } else { + $object->$key = $value; + } + break; case 'createdBefore': diff --git a/src/Camunda/Query/Parameters.php b/src/Camunda/Query/Parameters.php index 09ea5e0..016ac87 100644 --- a/src/Camunda/Query/Parameters.php +++ b/src/Camunda/Query/Parameters.php @@ -13,7 +13,8 @@ interface Parameters * Cast parameters to array * * @param boolean $minimal + * @param string $type * @return \stdClass */ - public function toObject(bool $minimal = false); + public function toObject(bool $minimal = false, $type = 'query'); } diff --git a/src/Camunda/Query/TaskParameters.php b/src/Camunda/Query/TaskParameters.php index 68db6ea..30ca1b5 100644 --- a/src/Camunda/Query/TaskParameters.php +++ b/src/Camunda/Query/TaskParameters.php @@ -14,7 +14,8 @@ final class TaskParameters implements Parameters use Attribute\TaskIdIn; use Attribute\Assignee; use Attribute\AssigneeLike; - use Attribute\CandidateGroup; + use Attribute\Unassigned; + use Attribute\CandidateGroups; use Attribute\IncludeAssignedTasks; use Attribute\CreatedBefore; use Attribute\CreatedAfter; diff --git a/src/Camunda/Service/TaskService.php b/src/Camunda/Service/TaskService.php index be35018..c0f5240 100644 --- a/src/Camunda/Service/TaskService.php +++ b/src/Camunda/Service/TaskService.php @@ -71,9 +71,10 @@ final class TaskService implements Service * Get task list * * @param \Ds\Component\Camunda\Query\TaskParameters $parameters + * @param array $orParameters * @return array */ - public function getList(Parameters $parameters = null) + public function getList(Parameters $parameters = null, array $orParameters = []) { $options = [ 'headers' => [ @@ -81,7 +82,7 @@ public function getList(Parameters $parameters = null) ] ]; - $query = (array) $parameters->toObject(true); + $query = (array) $parameters->toObject(true, 'body'); if (array_key_exists('taskIdIn', $query)) { $resource = static::RESOURCE_LIST_BY_TASK_ID.'?'; @@ -91,12 +92,61 @@ public function getList(Parameters $parameters = null) } $resource = substr($resource, 0, -1); + $objects = $this->execute('GET', $resource, $options); + + if (array_key_exists('sortBy', $query) && array_key_exists('sortOrder', $query)) { + $fields = [ + 'created' => 'startTime', + 'dueDate' => 'due' + ]; + + if (array_key_exists($query['sortBy'], $fields)) { + $field = $fields[$query['sortBy']]; + usort($objects, function($a, $b) use ($field) { + if ($a->$field == $b->$field) { + return 0; + } + + return ($a->$field < $b->$field) ? -1 : 1; + }); + + if ('desc' === $query['sortOrder']) { + $objects = array_reverse($objects); + } + } + } + +// if (array_key_exists('firstResult', $query) && array_key_exists('maxResults', $query)) { +// $objects = array_slice( +// $objects, +// $query['firstResult'], +// $query['maxResults'] +// ); +// } } else { $resource = static::RESOURCE_LIST; - $options['query'] = $query; + $options['json'] = $query; + + if ($orParameters) { + foreach ($orParameters as $orParameter) { + $orParameter = (array) $orParameter->toObject(true, 'body'); + + if ($orParameter) { + $options['json']['orQueries'][] = $orParameter; + } + } + } + + foreach (['firstResult', 'maxResults'] as $key) { + if (array_key_exists($key, $options['json'])) { + $options['query'][$key] = $options['json'][$key]; + unset($options['json'][$key]); + } + } + + $objects = $this->execute('POST', $resource, $options); } - $objects = $this->execute('GET', $resource, $options); $list = []; foreach ($objects as $object) { @@ -111,17 +161,38 @@ public function getList(Parameters $parameters = null) * Get count * * @param \Ds\Component\Camunda\Query\TaskParameters $parameters + * @param array $orParameters * @return integer */ - public function getCount(Parameters $parameters = null) + public function getCount(Parameters $parameters = null, array $orParameters = []) { $options = [ 'headers' => [ 'Accept' => 'application/json' - ], - 'query' => (array) $parameters->toObject(true) + ] ]; - $result = $this->execute('GET', static::RESOURCE_COUNT, $options); + $query = (array) $parameters->toObject(true, 'body'); + $resource = static::RESOURCE_COUNT; + $options['json'] = $query; + + if ($orParameters) { + foreach ($orParameters as $orParameter) { + $orParameter = (array) $orParameter->toObject(true, 'body'); + + if ($orParameter) { + $options['json']['orQueries'][] = $orParameter; + } + } + } + + foreach (['firstResult', 'maxResults'] as $key) { + if (array_key_exists($key, $options['json'])) { + $options['query'][$key] = $options['json'][$key]; + unset($options['json'][$key]); + } + } + + $result = $this->execute('POST', $resource, $options); return $result->count; } diff --git a/src/Config/Entity/Config.php b/src/Config/Entity/Config.php index 0ecf90d..3504594 100644 --- a/src/Config/Entity/Config.php +++ b/src/Config/Entity/Config.php @@ -94,8 +94,9 @@ class Config implements Identifiable, Uuidentifiable, Ownable, Encryptable, Vers /** * @var \DateTime - * @ApiProperty(writable=false) - * @Serializer\Groups({"config_output"}) + * @ApiProperty + * @Serializer\Groups({"config_output", "config_input"}) + * @Assert\DateTime */ protected $createdAt; diff --git a/src/Config/Fixture/Config.php b/src/Config/Fixture/Config.php index 64987a0..e62c079 100644 --- a/src/Config/Fixture/Config.php +++ b/src/Config/Fixture/Config.php @@ -2,6 +2,7 @@ namespace Ds\Component\Config\Fixture; +use DateTime; use Doctrine\Common\Persistence\ObjectManager; use Ds\Component\Config\Entity\Config as ConfigEntity; use Ds\Component\Database\Fixture\Yaml; @@ -36,6 +37,13 @@ public function load(ObjectManager $manager) ->setKey($object->key) ->setValue($object->value) ->setTenant($object->tenant); + + if (null !== $object->created_at) { + $date = new DateTime; + $date->setTimestamp($object->created_at); + $config->setCreatedAt($date); + } + $manager->persist($config); } diff --git a/src/Config/Resources/config/packages/ds_acl.yaml b/src/Config/Resources/config/packages/ds_acl.yaml index b87a575..adc33d9 100644 --- a/src/Config/Resources/config/packages/ds_acl.yaml +++ b/src/Config/Resources/config/packages/ds_acl.yaml @@ -6,6 +6,8 @@ ds_acl: config_property: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.*, title: ds_config.permissions.config.property } config_id: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.id, title: ds_config.permissions.config.id } config_uuid: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.uuid, title: ds_config.permissions.config.uuid } + config_created_at: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.createdAt, title: ds_config.permissions.config.created_at } + config_updated_at: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.updatedAt, title: ds_config.permissions.config.updated_at } config_owner: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.owner, title: ds_config.permissions.config.owner } config_owner_uuid: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.ownerUuid, title: ds_config.permissions.config.owner_uuid } config_key: { attributes: [BROWSE, READ, EDIT], property: Ds\Component\Config\Entity\Config.key, title: ds_config.permissions.config.key } diff --git a/src/Metadata/Entity/Metadata.php b/src/Metadata/Entity/Metadata.php index 7de5055..eb53b59 100644 --- a/src/Metadata/Entity/Metadata.php +++ b/src/Metadata/Entity/Metadata.php @@ -95,8 +95,9 @@ class Metadata implements Identifiable, Uuidentifiable, Sluggable, Ownable, Tran /** * @var \DateTime - * @ApiProperty(writable=false) - * @Serializer\Groups({"metadata_output"}) + * @ApiProperty + * @Serializer\Groups({"metadata_output", "metadata_input"}) + * @Assert\DateTime */ protected $createdAt; diff --git a/src/Metadata/Fixture/Metadata.php b/src/Metadata/Fixture/Metadata.php index 4567d5a..e971b8c 100644 --- a/src/Metadata/Fixture/Metadata.php +++ b/src/Metadata/Fixture/Metadata.php @@ -2,6 +2,7 @@ namespace Ds\Component\Metadata\Fixture; +use DateTime; use Doctrine\Common\Persistence\ObjectManager; use Ds\Component\Database\Fixture\Yaml; use Ds\Component\Metadata\Entity\Metadata as MetadataEntity; @@ -38,6 +39,13 @@ public function load(ObjectManager $manager) ->setType($object->type) ->setData((array) $object->data) ->setTenant($object->tenant); + + if (null !== $object->created_at) { + $date = new DateTime; + $date->setTimestamp($object->created_at); + $metadata->setCreatedAt($date); + } + $manager->persist($metadata); } diff --git a/src/Model/Attribute/Accessor/EntityUuids.php b/src/Model/Attribute/Accessor/EntityUuids.php new file mode 100644 index 0000000..d68d667 --- /dev/null +++ b/src/Model/Attribute/Accessor/EntityUuids.php @@ -0,0 +1,34 @@ +entityUuids = $entityUuids; + + return $this; + } + + /** + * Get entity uuids + * + * @return array + */ + public function getEntityUuids(): ?array + { + return $this->entityUuids; + } +} diff --git a/src/Model/Attribute/Accessor/Scope.php b/src/Model/Attribute/Accessor/Scope.php new file mode 100644 index 0000000..1dfd9d2 --- /dev/null +++ b/src/Model/Attribute/Accessor/Scope.php @@ -0,0 +1,35 @@ +scope = $scope; + + return $this; + } + + /** + * Get scope + * + * @return array + * @throws \OutOfRangeException + */ + public function getScope(): ?array + { + return $this->scope; + } +} diff --git a/src/Model/Attribute/EntityUuids.php b/src/Model/Attribute/EntityUuids.php new file mode 100644 index 0000000..e5ab92e --- /dev/null +++ b/src/Model/Attribute/EntityUuids.php @@ -0,0 +1,18 @@ +end() ->arrayNode('identity') ->children() + ->booleanNode('business_units') + ->defaultFalse() + ->end() ->booleanNode('roles') ->defaultFalse() ->end() diff --git a/src/Security/DependencyInjection/DsSecurityExtension.php b/src/Security/DependencyInjection/DsSecurityExtension.php index 8673086..9ca85b7 100644 --- a/src/Security/DependencyInjection/DsSecurityExtension.php +++ b/src/Security/DependencyInjection/DsSecurityExtension.php @@ -29,6 +29,7 @@ public function prepend(ContainerBuilder $container) 'client' => false, 'modifier' => false, 'identity' => [ + 'business_units' => false, 'roles' => false, 'type' => false, 'uuid' => false @@ -57,6 +58,7 @@ public function load(array $configs, ContainerBuilder $container) '["ip"]' => Token\IpListener::class, '["client"]' => Token\ClientListener::class, '["modifier"]' => Token\ModifierListener::class, + '["identity"]["business_units"]' => Token\Identity\BusinessUnitsListener::class, '["identity"]["roles"]' => Token\Identity\RolesListener::class, '["identity"]["type"]' => Token\Identity\TypeListener::class, '["identity"]["uuid"]' => Token\Identity\UuidListener::class diff --git a/src/Security/EventListener/Token/Identity/BusinessUnitsListener.php b/src/Security/EventListener/Token/Identity/BusinessUnitsListener.php new file mode 100644 index 0000000..31db0eb --- /dev/null +++ b/src/Security/EventListener/Token/Identity/BusinessUnitsListener.php @@ -0,0 +1,113 @@ +api = $api; + $this->accessor = PropertyAccess::createPropertyAccessor(); + $this->property = $property; + } + + /** + * Add the identity business units to the token + * + * @param \Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent $event + * @throws \Ds\Component\Security\Exception\InvalidUserTypeException + */ + public function created(JWTCreatedEvent $event) + { + $data = $event->getData(); + $user = $event->getUser(); + $businessUnits = []; + + // @todo remove condition when both user types are homogenized + if ($user instanceof User) { + $businessUnits = $user->getIdentity()->getBusinessUnits(); + } else { + if (null !== $user->getIdentityUuid()) { + switch ($user->getIdentity()) { + case Identity::ANONYMOUS: + case Identity::INDIVIDUAL: + case Identity::ORGANIZATION: + case Identity::SYSTEM: + $businessUnits = []; + break; + + case Identity::STAFF: + $identity = $this->api->get('identities.staff')->get($user->getIdentityUuid()); + + if (!$identity) { + throw new UnexpectedValueException; + } + + foreach ($identity->getBusinessUnits() as $businessUnit) { + $businessUnits[] = $businessUnit->getUuid(); + } + + break; + + default: + throw new DomainException('User identity is not valid.'); + } + } + } + + $this->accessor->setValue($data, $this->property, $businessUnits); + $event->setData($data); + } + + /** + * Mark the token as invalid if the identity roles is missing + * + * @param \Lexik\Bundle\JWTAuthenticationBundle\Event\JWTDecodedEvent $event + */ + public function decoded(JWTDecodedEvent $event) + { + $payload = $event->getPayload(); + + // Make property accessor paths compatible by converting payload to recursive associative array + $payload = json_decode(json_encode($payload), true); + + if (!$this->accessor->isReadable($payload, $this->property)) { + $event->markAsInvalid(); + } + } +} diff --git a/src/Security/EventListener/Token/Identity/RolesListener.php b/src/Security/EventListener/Token/Identity/RolesListener.php index 1ed22b9..a999770 100644 --- a/src/Security/EventListener/Token/Identity/RolesListener.php +++ b/src/Security/EventListener/Token/Identity/RolesListener.php @@ -9,6 +9,7 @@ use Ds\Component\Api\Query\OrganizationRoleParameters; use Ds\Component\Api\Query\StaffRoleParameters; use Ds\Component\Api\Query\SystemRoleParameters; +use Ds\Component\Api\Query\BusinessUnitRoleParameters; use Ds\Component\Security\Model\Identity; use Ds\Component\Security\Model\User; use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent; @@ -103,11 +104,48 @@ public function created(JWTCreatedEvent $event) } foreach ($identityRoles as $identityRole) { - $role = $identityRole->getRole()->getUuid(); - $roles[$role] = []; + $uuid = $identityRole->getRole()->getUuid(); - foreach ($identityRole->getBusinessUnits() as $businessUnit) { - $roles[$role][] = $businessUnit->getUuid(); + if (!array_key_exists($uuid, $roles)) { + $roles[$uuid] = []; + } + + foreach ($identityRole->getEntityUuids() as $entityUuid) { + if (!in_array($entityUuid, $roles[$uuid], true)) { + $roles[$uuid][] = $entityUuid; + } + } + } + + switch ($user->getIdentity()) { + case Identity::ANONYMOUS: + case Identity::INDIVIDUAL: + case Identity::ORGANIZATION: + case Identity::SYSTEM: + $businessUnitRoles = []; + break; + + case Identity::STAFF: + $parameters = new BusinessUnitRoleParameters; + $parameters->setStaffUuid($user->getIdentityUuid()); + $businessUnitRoles = $this->api->get('identities.business_unit_role')->getList($parameters); + break; + + default: + throw new DomainException('User identity is not valid.'); + } + + foreach ($businessUnitRoles as $businessUnitRole) { + $uuid = $businessUnitRole->getRole()->getUuid(); + + if (!array_key_exists($uuid, $roles)) { + $roles[$uuid] = []; + } + + foreach ($businessUnitRole->getEntityUuids() as $entityUuid) { + if (!in_array($entityUuid, $roles[$uuid], true)) { + $roles[$uuid][] = $entityUuid; + } } } } diff --git a/src/Security/Model/Attribute/Accessor/BusinessUnits.php b/src/Security/Model/Attribute/Accessor/BusinessUnits.php new file mode 100644 index 0000000..a5fce02 --- /dev/null +++ b/src/Security/Model/Attribute/Accessor/BusinessUnits.php @@ -0,0 +1,35 @@ +businessUnits = $businessUnits; + + return $this; + } + + /** + * Get business units + * + * @return array + */ + public function getBusinessUnits(): array + { + return $this->businessUnits; + } +} diff --git a/src/Security/Model/Attribute/BusinessUnits.php b/src/Security/Model/Attribute/BusinessUnits.php new file mode 100644 index 0000000..528dd8a --- /dev/null +++ b/src/Security/Model/Attribute/BusinessUnits.php @@ -0,0 +1,18 @@ +roles = []; + $this->businessUnits = []; + } } diff --git a/src/Security/Model/User.php b/src/Security/Model/User.php index 3e75fcf..4d13716 100644 --- a/src/Security/Model/User.php +++ b/src/Security/Model/User.php @@ -21,9 +21,10 @@ public static function createFromPayload($username, array $payload) $uuid = $payload['uuid'] ?? null; $roles = $payload['roles'] ?? []; $identity = new Identity; - $identity->setRoles((array) $payload['identity']->roles ?? []); - $identity->setType($payload['identity']->type ?? null); - $identity->setUuid($payload['identity']->uuid ?? null); + $identity->setRoles(array_key_exists('identity', $payload) && property_exists($payload['identity'], 'roles') ? (array) $payload['identity']->roles : []); + $identity->setBusinessUnits(array_key_exists('identity', $payload) && property_exists($payload['identity'], 'business_units') ? (array) $payload['identity']->business_units : []); + $identity->setType(array_key_exists('identity', $payload) && property_exists($payload['identity'], 'type') ? $payload['identity']->type : null); + $identity->setUuid(array_key_exists('identity', $payload) && property_exists($payload['identity'], 'uuid') ? $payload['identity']->uuid : null); $tenant = $payload['tenant'] ?? null; return new static($username, $uuid, $roles, $identity, $tenant); @@ -132,4 +133,12 @@ public function getSalt() public function eraseCredentials() { } + + /** + * Clone instance + */ + public function __clone() + { + $this->identity = clone $this->identity; + } } diff --git a/src/Security/Test/Context/UserContext.php b/src/Security/Test/Context/UserContext.php index ab81c56..eb27edf 100644 --- a/src/Security/Test/Context/UserContext.php +++ b/src/Security/Test/Context/UserContext.php @@ -7,7 +7,9 @@ use DomainException; use Ds\Component\Security\Test\Collection\UserCollection; use Ds\Component\Security\Model\User; +use Ds\Component\Security\Model\Identity; use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface; +use ReflectionClass; /** * Class UserContext @@ -46,7 +48,7 @@ public function __construct(Request $request, JWTTokenManagerInterface $tokenMan } /** - * Set authorization header + * Set authorization header with a given user from a tenant * * @Given I am authenticated as the :username user from the tenant :tenant * @param string $username @@ -65,4 +67,33 @@ public function iAmAuthenticatedAsTheUserFromTheTenant(string $username, string $token = $this->tokenManager->create($user); $this->request->setHttpHeader('Authorization', 'Bearer '.$token); } + + /** + * Set authorization header with a given user and identity role from a tenant + * + * @Given I am authenticated as the :username user with identity role :role from the tenant :tenant + * @param string $username + * @param string $role + * @param string $tenant + */ + public function iAmAuthenticatedAsTheUserWithIdentityRoleFromTheTenant(string $username, string $role, string $tenant) + { + $user = $this->userCollection->filter(function(User $user) use ($username, $tenant) { + return $user->getUsername() === $username && $user->getTenant() === $tenant; + })->first(); + + if (!$user) { + throw new DomainException('User "'.$username.'" for tenant "'.$tenant.'" does not exist.'); + } + + $clone = clone $user; + $identity = $clone->getIdentity(); + $class = new ReflectionClass(Identity::class); + $property = $class->getProperty('roles'); + $property->setAccessible(true); + $property->setValue($identity, [$role => []]); + $property->setAccessible(false); + $token = $this->tokenManager->create($clone); + $this->request->setHttpHeader('Authorization', 'Bearer '.$token); + } } diff --git a/src/Security/Test/DependencyInjection/Configuration.php b/src/Security/Test/DependencyInjection/Configuration.php index 5d44da1..767b3d6 100644 --- a/src/Security/Test/DependencyInjection/Configuration.php +++ b/src/Security/Test/DependencyInjection/Configuration.php @@ -35,6 +35,8 @@ public function getConfigTreeBuilder() ->children() ->arrayNode('roles') ->end() + ->arrayNode('business_units') + ->end() ->enumNode('type') ->values([Identity::SYSTEM, Identity::STAFF, Identity::ORGANIZATION, Identity::INDIVIDUAL, Identity::ANONYMOUS]) ->end() diff --git a/src/Security/Test/Resources/config/config.yaml b/src/Security/Test/Resources/config/config.yaml index 8c80ea2..3326d44 100644 --- a/src/Security/Test/Resources/config/config.yaml +++ b/src/Security/Test/Resources/config/config.yaml @@ -5,6 +5,7 @@ ds_security_test: uuid: 85726ec8-7685-4655-ac91-4746ae71c2cc identity: roles: [] + business_units: [] type: System uuid: aa18b644-a503-49fa-8f53-10f4c1f8e3a1 tenant: b6ac25fe-3cd6-4100-a054-6bba2fc9ef18 # Tenant 1 @@ -14,6 +15,7 @@ ds_security_test: uuid: da7bde0e-9690-43b3-abbb-043fb61c679a identity: roles: [] + business_units: [] type: Staff uuid: e9111144-71fa-4743-91d0-178653d2e385 tenant: b6ac25fe-3cd6-4100-a054-6bba2fc9ef18 # Tenant 1 @@ -23,6 +25,7 @@ ds_security_test: uuid: 765eaf87-51f9-44f4-8758-c28f380a0855 identity: roles: [] + business_units: [] type: Organization uuid: cedcdc35-84a8-4fa1-8cf1-ff8fea926222 tenant: b6ac25fe-3cd6-4100-a054-6bba2fc9ef18 # Tenant 1 @@ -32,6 +35,7 @@ ds_security_test: uuid: 0bf2117e-56b4-47ae-87cd-88d7a8bfc758 identity: roles: [] + business_units: [] type: Individual uuid: 5da1504c-68a4-4968-a03a-36e6ac39e8f2 tenant: b6ac25fe-3cd6-4100-a054-6bba2fc9ef18 # Tenant 1 @@ -41,6 +45,7 @@ ds_security_test: uuid: f9df049a-fe95-405f-ba7c-734f1a0ce558 identity: roles: [] + business_units: [] type: Anonymous uuid: ad1a4ee4-b707-4135-b8e9-498286d5830c tenant: b6ac25fe-3cd6-4100-a054-6bba2fc9ef18 # Tenant 1 @@ -50,6 +55,7 @@ ds_security_test: uuid: d6a5c45e-2e14-4dd0-b1eb-7bd36db3fcf6 identity: roles: [] + business_units: [] type: System uuid: 571c4b5f-532e-48ac-aa6a-8099d57d9088 tenant: 92000deb-b847-4838-915c-b95d2b28e960 # Tenant 2 @@ -59,6 +65,7 @@ ds_security_test: uuid: 36dd306a-b31a-432e-9d6a-3c7d37a8091d identity: roles: [] + business_units: [] type: Staff uuid: 45528ca5-5c61-4d42-9584-d62614ea6a6e tenant: 92000deb-b847-4838-915c-b95d2b28e960 # Tenant 2 @@ -68,6 +75,7 @@ ds_security_test: uuid: 91a6830f-993e-4072-822e-64a94dfe9122 identity: roles: [] + business_units: [] type: Organization uuid: fca280ca-18c7-477d-bf95-de51f1a1d54d tenant: 92000deb-b847-4838-915c-b95d2b28e960 # Tenant 2 @@ -77,6 +85,7 @@ ds_security_test: uuid: a3586ba3-8b4f-4c8b-a913-0dafb57702df identity: roles: [] + business_units: [] type: Individual uuid: 3967473b-edf1-4011-807e-f6b85a7660dd tenant: 92000deb-b847-4838-915c-b95d2b28e960 # Tenant 2 @@ -86,6 +95,57 @@ ds_security_test: uuid: 0f642313-3a79-4920-abb7-380e815514a6 identity: roles: [] + business_units: [] type: Anonymous uuid: 7fd2e84f-b8d6-435d-9339-127e244e8fd0 tenant: 92000deb-b847-4838-915c-b95d2b28e960 # Tenant 2 + + - username: system@system.ds + roles: [] + uuid: 136b84a2-d409-4845-9415-787f54f96899 + identity: + roles: [] + business_units: [] + type: System + uuid: 36315b58-1502-43e9-bb9c-ebe49ff321ab + tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + + - username: staff@staff.ds + roles: [] + uuid: cc1789bd-de4d-40aa-a018-3a50b8ed404e + identity: + roles: [] + business_units: [] + type: Staff + uuid: 7878154e-cda3-48a4-95cf-71bc4b2cc3ba + tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + + - username: organization@organization.ds + roles: [] + uuid: a1fd365d-6340-4450-a9ab-f3b67a1e92bf + identity: + roles: [] + business_units: [] + type: Organization + uuid: 31f65051-399d-4f2f-ab40-5ae19e03ff70 + tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + + - username: individual@individual.ds + roles: [] + uuid: 12cd4c49-b8b5-4d35-8269-4635e69d52b0 + identity: + roles: [] + business_units: [] + type: Individual + uuid: 1d897239-763f-4e90-9bf8-6976431e36cd + tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + + - username: anonymous@anonymous.ds + roles: [] + uuid: 4a70d4f9-45f7-4afd-a3ec-9383fcc190b6 + identity: + roles: [] + business_units: [] + type: Anonymous + uuid: e0b59434-be1e-407d-b70c-fc2b52ebb4b5 + tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 diff --git a/src/Tenant/Entity/Tenant.php b/src/Tenant/Entity/Tenant.php index af4952e..4d5f916 100644 --- a/src/Tenant/Entity/Tenant.php +++ b/src/Tenant/Entity/Tenant.php @@ -70,8 +70,9 @@ class Tenant implements Identifiable, Uuidentifiable, Versionable /** * @var \DateTime - * @ApiProperty(writable=false) - * @Serializer\Groups({"tenant_output"}) + * @ApiProperty + * @Serializer\Groups({"tenant_output", "tenant_input"}) + * @Assert\DateTime */ protected $createdAt; diff --git a/src/Tenant/Fixture/Tenant.php b/src/Tenant/Fixture/Tenant.php index fb8a975..d1fb710 100644 --- a/src/Tenant/Fixture/Tenant.php +++ b/src/Tenant/Fixture/Tenant.php @@ -2,6 +2,7 @@ namespace Ds\Component\Tenant\Fixture; +use DateTime; use Doctrine\Common\Persistence\ObjectManager; use Ds\Component\Database\Fixture\Yaml; use Ds\Component\Tenant\Entity\Tenant as TenantEntity; @@ -44,6 +45,13 @@ public function load(ObjectManager $manager) $tenant ->setUuid($object->uuid) ->setData((array) $object->data); + + if (null !== $object->created_at) { + $date = new DateTime; + $date->setTimestamp($object->created_at); + $tenant->setCreatedAt($date); + } + $manager->persist($tenant); }