From 7caa21bd878923e318dc5e4b214846fb11eae96c Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Thu, 30 Jul 2020 14:49:31 -0400 Subject: [PATCH 01/29] Add property-based permission scope --- .../ORM/QueryExtension/EntityExtension.php | 257 +++++++++++++++++- src/Acl/Entity/Access.php | 4 +- src/Acl/Entity/Attribute/Accessor/Entity.php | 43 --- .../Entity/Attribute/Accessor/EntityUuid.php | 43 --- src/Acl/Entity/Attribute/Accessor/Scope.php | 10 +- src/Acl/Entity/Attribute/Accessor/Type.php | 64 ----- src/Acl/Entity/Permission.php | 16 +- src/Acl/Entity/Scope.php | 52 ---- src/Acl/EventListener/ExceptionListener.php | 3 +- src/Acl/Fixture/Permission.php | 8 +- src/Acl/Migration/Version0_19_0.php | 68 +++++ src/Acl/Service/AccessService.php | 22 +- src/Acl/Tenant/Loader/Acl.php | 8 +- src/Acl/Voter/EntityVoter.php | 50 +++- src/Acl/Voter/PropertyVoter.php | 50 +++- 15 files changed, 428 insertions(+), 270 deletions(-) delete mode 100644 src/Acl/Entity/Attribute/Accessor/Entity.php delete mode 100644 src/Acl/Entity/Attribute/Accessor/EntityUuid.php delete mode 100644 src/Acl/Entity/Attribute/Accessor/Type.php delete mode 100644 src/Acl/Entity/Scope.php create mode 100644 src/Acl/Migration/Version0_19_0.php diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index e15e965..229a76a 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; } /** @@ -90,7 +101,10 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } - switch ($permission->getScope()->getType()) { + $scope = $permission->getScope(); + $type = array_key_exists('type', $scope) ? $scope['type'] : null; + + switch ($type) { case 'generic': // This permission grants access to all entities of the class, no conditions need to be applied. return; @@ -101,8 +115,13 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } + if (!array_key_exists('entity_uuid', $scope)) { + // Skip permissions without entity_uuid defined. + continue; + } + $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_uuid_'.$i); - $parameters['ds_security_uuid_'.$i] = $permission->getScope()->getEntityUuid(); + $parameters['ds_security_uuid_'.$i] = $scope['entity_uuid']; $i++; break; @@ -113,6 +132,11 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } + if (!array_key_exists('entity_uuid', $scope)) { + // Skip permissions without entity_uuid defined. + continue; + } + if (in_array($resourceClass, [ 'App\\Entity\\Anonymous', 'App\\Entity\\Individual', @@ -120,7 +144,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator 'App\\Entity\\Staff' ])) { $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_identity_'.$i); - $parameters['ds_security_identity_'.$i] = $permission->getScope()->getEntity(); + $parameters['ds_security_identity_'.$i] = $scope['entity_uuid']; } else if (in_array($resourceClass, [ 'App\\Entity\\AnonymousPersona', 'App\\Entity\\IndividualPersona', @@ -138,14 +162,19 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator ->where($alias.'.uuid = :ds_security_identity_uuid_'.$i) ->getDQL() ); - $parameters['ds_security_identity_uuid_'.$i] = $permission->getScope()->getEntityUuid(); + $parameters['ds_security_identity_uuid_'.$i] = $scope['entity_uuid']; } else { + if (!array_key_exists('entity', $scope)) { + // Skip permissions without entity defined. + continue; + } + $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(); + $parameters['ds_security_identity_'.$i] = $scope['entity']; + $parameters['ds_security_identity_uuid_'.$i] = $scope['entity_entity']; } $i++; @@ -158,16 +187,23 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } - if (null === $permission->getScope()->getEntityUuid()) { + if (!array_key_exists('entity', $scope)) { + // Skip permissions without entity defined. + continue; + } + + $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; + + if (null === $entityUuid) { $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.owner', ':ds_security_owner_'.$i); - $parameters['ds_security_owner_'.$i] = $permission->getScope()->getEntity(); + $parameters['ds_security_owner_'.$i] = $scope['entity']; } 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(); + $parameters['ds_security_owner_'.$i] = $scope['entity']; + $parameters['ds_security_owner_uuid_'.$i] = $entityUuid; } $i++; @@ -220,6 +256,109 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator break; + case 'property': + $property = array_key_exists('property', $scope) ? $scope['property'] : null; + $value = array_key_exists('value', $scope) ? $scope['value'] : null; + $comparison = array_key_exists('comparison', $scope) ? $scope['comparison'] : 'eq'; + + if (null === $property) { + // Skip permissions that do not define a property. + continue; + } + + if (!in_array($comparison, ['eq', 'neq'], 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; + } + + $parts = explode('.', $property); + $property = array_shift($parts); + $path = str_replace('\'', '', implode('.', $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) { + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + + if (null === $value) { + if ('eq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNull($translationAlias . '.' . $property); + } else if ('neq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNotNull($translationAlias . '.' . $property); + } + } else { + $conditions[] = $queryBuilder->expr()->{$comparison}($translationAlias . '.' . $property, ':ds_security_property_' . $i); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('translation.json' === $field) { + if ('' === $path) { + // Skip permissions that do not specify json path. + continue; + } + + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + $value = $this->typeCast($value); + + if (null === $value) { + if ('eq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + } else if ('neq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + } + } else { + $conditions[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('json' === $field) { + if ('' === $path) { + // Skip permissions that do not specify json path. + continue; + } + + $value = $this->typeCast($value); + + if (null === $value) { + if ('eq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + } else if ('neq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + } + } else { + $conditions[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('basic' === $field) { + if ('' !== $path) { + // Skip permissions that do not specify an existing property on the entity. + continue; + } + + if (null === $value) { + if ('eq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNull($rootAlias . '.' . $property); + } else if ('neq' === $comparison) { + $conditions[] = $queryBuilder->expr()->isNotNull($rootAlias . '.' . $property); + } + } else { + $conditions[] = $queryBuilder->expr()->{$comparison}($rootAlias . '.' . $property, ':ds_security_property_' . $i); + $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. @@ -237,5 +376,101 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator foreach ($parameters as $key => $value) { $queryBuilder->setParameter($key, $value); } + + echo $queryBuilder->getQuery()->getSQL();exit; + } + + /** + * 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'; + } + + /** + * Add a translation join entry, if not already present + * + * @param QueryBuilder $queryBuilder + * @param string $resourceClass + * @return string + */ + private function addJoinTranslation(QueryBuilder $queryBuilder, string $resourceClass): string + { + $rootAlias = $queryBuilder->getRootAliases()[0]; + $translationAlias = $rootAlias . '_t'; + $parts = $queryBuilder->getDQLParts()['join']; + + foreach ($parts as $joins) { + foreach ($joins as $join) { + if ($translationAlias === $join->getAlias()) { + return $translationAlias; + } + } + } + + $queryBuilder->innerJoin($rootAlias . '.translations', $translationAlias); + + 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..0a0b680 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", 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..adeda30 100644 --- a/src/Acl/Entity/Attribute/Accessor/Scope.php +++ b/src/Acl/Entity/Attribute/Accessor/Scope.php @@ -2,8 +2,6 @@ namespace Ds\Component\Acl\Entity\Attribute\Accessor; -use Ds\Component\Acl\Entity\Scope as ScopeEntity; - /** * Trait Scope * @@ -14,10 +12,10 @@ trait Scope /** * Set scope * - * @param \Ds\Component\Acl\Entity\Scope $scope + * @param array $scope * @return object */ - public function setScope(?ScopeEntity $scope) + public function setScope(?array $scope) { $this->scope = $scope; @@ -27,9 +25,9 @@ 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; } 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/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..c474ad8 --- /dev/null +++ b/src/Acl/Migration/Version0_19_0.php @@ -0,0 +1,68 @@ +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 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..dbeab04 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 + // Identity wide permissions $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 + // Identity specific permissions $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 + // Role permissions $roles = $user->getIdentity()->getRoles(); $accesses = $this->repository->findBy([ @@ -87,11 +87,21 @@ 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()) { + if ( + array_key_exists('entity_uuid', $scope) + && '*' === $scope['entity_uuid'] + ) { + if ( + array_key_exists('type', $scope) + && 'owner' === $scope['type'] + && array_key_exists('entity', $scope) + && 'BusinessUnit' === $scope['entity'] + ) { foreach ($roles[$role] as $businessUnit) { $clone = clone $permission; - $clone->getScope()->setEntityUuid($businessUnit); + $cloneScope = $clone->getScope(); + $cloneScope['entity_uuid'] = $businessUnit; + $clone->setScope($cloneScope); $permissions->add($clone); } } else { 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..7848033 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -9,6 +9,7 @@ use Ds\Component\Model\Type\Ownable; use Ds\Component\Model\Type\Uuidentifiable; use Ds\Component\Security\Model\User; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -33,6 +34,11 @@ final class EntityVoter extends Voter */ private $entityCollection; + /** + * @var \Symfony\Component\PropertyAccess\PropertyAccessor + */ + private $accessor; + /** * Constructor * @@ -43,6 +49,7 @@ public function __construct(AccessService $accessService, EntityCollection $enti { $this->accessService = $accessService; $this->entityCollection = $entityCollection; + $this->accessor = PropertyAccess::createPropertyAccessor(); } /** @@ -92,7 +99,12 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - switch ($permission->getScope()->getType()) { + $scope = $permission->getScope(); + $type = array_key_exists('type', $scope) ? $scope['type'] : null; + $entity = array_key_exists('entity', $scope) ? $scope['entity'] : null; + $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; + + switch ($type) { case 'generic': // Nothing to specifically validate. break; @@ -103,7 +115,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if ($permission->getScope()->getEntityUuid() !== $subject->getUuid()) { + if ($entityUuid !== $subject->getUuid()) { // Skip permissions that do not match the subject entity uuid. continue; } @@ -116,15 +128,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject->getIdentity()) { + if (null !== $entity) { + if ($entity !== $subject->getIdentity()) { // Skip permissions that do not match the subject entity identity. continue; } } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject->getIdentityUuid()) { + if (null !== $entityUuid) { + if ($entityUuid !== $subject->getIdentityUuid()) { // Skip permissions that do not match the subject entity identity uuid. continue; } @@ -138,15 +150,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject->getOwner()) { + if (null !== $entity) { + if ($entity !== $subject->getOwner()) { // Skip permissions that do not match the subject entity owner. continue; } } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject->getOwnerUuid()) { + if (null !== $entityUuid) { + if ($entityUuid !== $subject->getOwnerUuid()) { // Skip permissions that do not match the subject entity owner uuid. continue; } @@ -172,6 +184,24 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) break; + case 'property': + $property = array_key_exists('property', $scope) ? $scope['property'] : null; + $value = array_key_exists('value', $scope) ? $scope['value'] : null; + + if (null === $property) { + continue; + } + + if (!$this->accessor->isReadable($subject, $property)) { + continue; + } + + if (!$this->accessor->getValue($subject, $property) !== $value) { + continue; + } + + break; + default: // Skip permissions with unknown scopes. In theory, this case should never // be selected unless there are data integrity issues. diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 5b02714..0e39c27 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -9,6 +9,7 @@ use Ds\Component\Model\Type\Ownable; use Ds\Component\Model\Type\Uuidentifiable; use Ds\Component\Security\Model\User; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\Voter\Voter; @@ -33,6 +34,11 @@ final class PropertyVoter extends Voter */ private $entityCollection; + /** + * @var \Symfony\Component\PropertyAccess\PropertyAccessor + */ + private $accessor; + /** * Constructor * @@ -43,6 +49,7 @@ public function __construct(AccessService $accessService, EntityCollection $enti { $this->accessService = $accessService; $this->entityCollection = $entityCollection; + $this->accessor = PropertyAccess::createPropertyAccessor(); } /** @@ -112,7 +119,12 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - switch ($permission->getScope()->getType()) { + $scope = $permission->getScope(); + $type = array_key_exists('type', $scope) ? $scope['type'] : null; + $entity = array_key_exists('entity', $scope) ? $scope['entity'] : null; + $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; + + switch ($type) { case 'generic': // Nothing to specifically validate. break; @@ -123,7 +135,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if ($permission->getScope()->getEntityUuid() !== $subject->getUuid()) { + if ($entityUuid !== $subject->getUuid()) { // Skip permissions that do not match the subject entity uuid. continue; } @@ -136,15 +148,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject[0]->getIdentity()) { + if (null !== $entity) { + if ($entity !== $subject[0]->getIdentity()) { // Skip permissions that do not match the subject entity identity. continue; } } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject[0]->getIdentityUuid()) { + if (null !== $entityUuid) { + if ($entityUuid !== $subject[0]->getIdentityUuid()) { // Skip permissions that do not match the subject entity identity uuid. continue; } @@ -158,15 +170,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (null !== $permission->getScope()->getEntity()) { - if ($permission->getScope()->getEntity() !== $subject[0]->getOwner()) { + if (null !== $entity) { + if ($entity !== $subject[0]->getOwner()) { // Skip permissions that do not match the subject entity owner. continue; } } - if (null !== $permission->getScope()->getEntityUuid()) { - if ($permission->getScope()->getEntityUuid() !== $subject[0]->getOwnerUuid()) { + if (null !== $entityUuid) { + if ($entityUuid !== $subject[0]->getOwnerUuid()) { // Skip permissions that do not match the subject entity owner uuid. continue; } @@ -192,6 +204,24 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) break; + case 'property': + $property = array_key_exists('property', $scope) ? $scope['property'] : null; + $value = array_key_exists('value', $scope) ? $scope['value'] : null; + + if (null === $property) { + continue; + } + + if (!$this->accessor->isReadable($subject, $property)) { + continue; + } + + if (!$this->accessor->getValue($subject, $property) !== $value) { + continue; + } + + break; + default: // Skip permissions with unknown scopes. In theory, this case should never // be selected unless there are data integrity issues. From 4d9db3d172e723a090e42cf904178d0c22e102b7 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sat, 1 Aug 2020 16:31:39 -0400 Subject: [PATCH 02/29] Add multi-scope permissions --- .../ORM/QueryExtension/EntityExtension.php | 457 +++++++++--------- src/Acl/Entity/Attribute/Accessor/Scope.php | 45 ++ src/Acl/Voter/EntityVoter.php | 233 ++++++--- src/Acl/Voter/PropertyVoter.php | 233 ++++++--- 4 files changed, 583 insertions(+), 385 deletions(-) diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index 229a76a..15306c2 100644 --- a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php +++ b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php @@ -80,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; @@ -101,283 +101,290 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } - $scope = $permission->getScope(); - $type = array_key_exists('type', $scope) ? $scope['type'] : null; + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $subWheres = []; - switch ($type) { - case 'generic': - // This permission grants access to all entities of the class, no conditions need to be applied. - return; + foreach ($conditions as $condition) { + $type = isset($condition['type']) ? $condition['type'] : null; - case 'object': - if (!in_array(Uuidentifiable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "object" if the entity is not uuidentifiable. - continue; - } + switch ($type) { + case 'generic': + // This permission grants access to all entities of the class, no where conditions need to be applied. + return; - if (!array_key_exists('entity_uuid', $scope)) { - // Skip permissions without entity_uuid defined. - continue; - } + case 'object': + if (!in_array(Uuidentifiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "object" if the entity is not uuidentifiable. + continue; + } - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.uuid', ':ds_security_uuid_'.$i); - $parameters['ds_security_uuid_'.$i] = $scope['entity_uuid']; - $i++; + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } - break; + $subWheres[] = $queryBuilder->expr()->eq($rootAlias . '.uuid', ':ds_security_uuid_' . $i); + $parameters['ds_security_uuid_' . $i] = $condition['entity_uuid']; + $i++; - case 'identity': - if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "identity" if the entity is not identitiable. - continue; - } + break; - if (!array_key_exists('entity_uuid', $scope)) { - // Skip permissions without entity_uuid defined. - 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] = $scope['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()); - $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] = $scope['entity_uuid']; - } else { - if (!array_key_exists('entity', $scope)) { - // Skip permissions without entity defined. + case 'identity': + if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "identity" if the entity is not identitiable. continue; } - $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] = $scope['entity']; - $parameters['ds_security_identity_uuid_'.$i] = $scope['entity_entity']; - } + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - $i++; + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } - break; + 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']; + } - case 'owner': - if (!in_array(Ownable::class, class_implements($resourceClass), true)) { - // Skip permissions with scope "owner" if the entity is not ownable. - continue; - } + $i++; - if (!array_key_exists('entity', $scope)) { - // Skip permissions without entity defined. - continue; - } + break; - $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; + 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 === $entityUuid) { - $conditions[] = $queryBuilder->expr()->eq($rootAlias.'.owner', ':ds_security_owner_'.$i); - $parameters['ds_security_owner_'.$i] = $scope['entity']; - } 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] = $scope['entity']; - $parameters['ds_security_owner_uuid_'.$i] = $entityUuid; - } + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - $i++; + $entityUuid = isset($condition['entity_uuid']) ? $condition['entity_uuid'] : null; - break; + 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; + } - 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; - - case 'property': - $property = array_key_exists('property', $scope) ? $scope['property'] : null; - $value = array_key_exists('value', $scope) ? $scope['value'] : null; - $comparison = array_key_exists('comparison', $scope) ? $scope['comparison'] : 'eq'; - - if (null === $property) { - // Skip permissions that do not define a property. - continue; - } + $i++; - if (!in_array($comparison, ['eq', 'neq'], true)) { - // Skip permissions that do not have supported comparison types. - continue; - } + break; - if (!in_array(gettype($value), ['string', 'boolean', 'integer', 'double', 'NULL'], true)) { - // Skip permissions that do not have supported value types. - continue; - } + case 'session': + if (!in_array(Identitiable::class, class_implements($resourceClass), true)) { + // Skip permissions with scope "session" if the entity is not identitiable. + continue; + } - $parts = explode('.', $property); - $property = array_shift($parts); - $path = str_replace('\'', '', implode('.', $parts)); + // @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(); + } - if (!property_exists($resourceClass, $property)) { - // Skip permissions that do not specify an existing property on the entity. - continue; - } + $i++; - $field = $this->getField($resourceClass, $property); + break; - if ('translation.scalar' === $field) { - $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + 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 === $value) { - if ('eq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNull($translationAlias . '.' . $property); - } else if ('neq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNotNull($translationAlias . '.' . $property); - } - } else { - $conditions[] = $queryBuilder->expr()->{$comparison}($translationAlias . '.' . $property, ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; - } - } else if ('translation.json' === $field) { - if ('' === $path) { - // Skip permissions that do not specify json path. + if (null === $property) { + // Skip permissions that do not define a property. continue; } - $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); - $value = $this->typeCast($value); - - if (null === $value) { - if ('eq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); - } else if ('neq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); - } - } else { - $conditions[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; + if (!in_array($comparison, ['eq', 'neq'], true)) { + // Skip permissions that do not have supported comparison types. + continue; } - } else if ('json' === $field) { - if ('' === $path) { - // Skip permissions that do not specify json path. + + if (!in_array(gettype($value), ['string', 'boolean', 'integer', 'double', 'NULL'], true)) { + // Skip permissions that do not have supported value types. continue; } - $value = $this->typeCast($value); + $parts = explode('.', $property); + $property = array_shift($parts); + $path = str_replace('\'', '', implode('.', $parts)); - if (null === $value) { - if ('eq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); - } else if ('neq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); - } - } else { - $conditions[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; - } - } else if ('basic' === $field) { - if ('' !== $path) { + if (!property_exists($resourceClass, $property)) { // Skip permissions that do not specify an existing property on the entity. continue; } - if (null === $value) { - if ('eq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNull($rootAlias . '.' . $property); - } else if ('neq' === $comparison) { - $conditions[] = $queryBuilder->expr()->isNotNull($rootAlias . '.' . $property); + $field = $this->getField($resourceClass, $property); + + if ('translation.scalar' === $field) { + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + + 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); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('translation.json' === $field) { + if ('' === $path) { + // Skip permissions that do not specify json path. + continue; + } + + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + $value = $this->typeCast($value); + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('json' === $field) { + if ('' === $path) { + // Skip permissions that do not specify json path. + continue; + } + + $value = $this->typeCast($value); + + if (null === $value) { + if ('eq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + } else if ('neq' === $comparison) { + $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + } + } else { + $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $parameters['ds_security_property_' . $i] = $value; + } + } else if ('scalar' === $field) { + if ('' !== $path) { + // 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); + $parameters['ds_security_property_' . $i] = $value; } - } else { - $conditions[] = $queryBuilder->expr()->{$comparison}($rootAlias . '.' . $property, ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; } - } - $i++; + $i++; - break; + 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; + 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 ($subWheres) { + $wheres[] = call_user_func_array([$queryBuilder->expr(), $operator . 'X'], $subWheres); } } - if (!$conditions) { + if (!$wheres) { throw new NoPermissionsException; } - $queryBuilder->andWhere(call_user_func_array([$queryBuilder->expr(), 'orX'], $conditions)); + $queryBuilder->andWhere(call_user_func_array([$queryBuilder->expr(), 'orX'], $wheres)); foreach ($parameters as $key => $value) { $queryBuilder->setParameter($key, $value); } - - echo $queryBuilder->getQuery()->getSQL();exit; } /** diff --git a/src/Acl/Entity/Attribute/Accessor/Scope.php b/src/Acl/Entity/Attribute/Accessor/Scope.php index adeda30..51107f1 100644 --- a/src/Acl/Entity/Attribute/Accessor/Scope.php +++ b/src/Acl/Entity/Attribute/Accessor/Scope.php @@ -2,6 +2,8 @@ namespace Ds\Component\Acl\Entity\Attribute\Accessor; +use LogicException; + /** * Trait Scope * @@ -14,6 +16,7 @@ trait Scope * * @param array $scope * @return object + * @throws */ public function setScope(?array $scope) { @@ -31,4 +34,46 @@ 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/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index 7848033..d4b6a0e 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -99,117 +99,190 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - $scope = $permission->getScope(); - $type = array_key_exists('type', $scope) ? $scope['type'] : null; - $entity = array_key_exists('entity', $scope) ? $scope['entity'] : null; - $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; - - switch ($type) { - case 'generic': - // Nothing to specifically validate. - break; - - case 'object': - if (!$subject instanceof Uuidentifiable) { - // Skip permissions with scope "object" if the subject entity is not uuidentitiable. - continue; - } + if (!in_array($attribute, $permission->getAttributes(), true)) { + // Skip permissions that do not contain the required attribute. + continue; + } - if ($entityUuid !== $subject->getUuid()) { - // Skip permissions that do not match the subject entity uuid. - continue; - } + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $results = []; - break; + foreach ($conditions as $condition) { + $result = null; + $type = isset($condition['type']) ? $condition['type'] : null; - case 'identity': - if (!$subject instanceof Identitiable) { - // Skip permissions with scope "identity" if the subject entity is not identitiable. - continue; - } + switch ($type) { + case 'generic': + // Nothing to specifically validate. + $result = true; + break; - if (null !== $entity) { - if ($entity !== $subject->getIdentity()) { - // Skip permissions that do not match the subject entity identity. + case 'object': + if (!$subject instanceof Uuidentifiable) { + // Skip permissions with scope "object" if the subject entity is not uuidentitiable. continue; } - } - if (null !== $entityUuid) { - if ($entityUuid !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. continue; } - } - break; + $result = true; - case 'owner': - if (!$subject instanceof Ownable) { - // Skip permissions with scope "owner" if the subject entity is not ownable. - continue; - } + if ($condition['entity_uuid'] !== $subject->getUuid()) { + $result = false; + } + + break; - if (null !== $entity) { - if ($entity !== $subject->getOwner()) { - // Skip permissions that do not match the subject entity owner. + case 'identity': + if (!$subject instanceof Identitiable) { + // Skip permissions with scope "identity" if the subject entity is not identitiable. continue; } - } - if (null !== $entityUuid) { - if ($entityUuid !== $subject->getOwnerUuid()) { - // Skip permissions that do not match the subject entity owner uuid. + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. continue; } - } - break; + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } - 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()) { - // Skip permissions that do not match the subject entity identity. - continue; - } + if ($condition['entity'] !== $subject->getIdentity()) { + $result = false; + } - if ($user->getIdentity()->getUuid() !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. - continue; - } + if ($condition['entity_uuid'] !== $subject->getIdentityUuid()) { + $result = false; + } - break; + break; - case 'property': - $property = array_key_exists('property', $scope) ? $scope['property'] : null; - $value = array_key_exists('value', $scope) ? $scope['value'] : null; + case 'owner': + if (!$subject instanceof Ownable) { + // Skip permissions with scope "owner" if the subject entity is not ownable. + continue; + } - if (null === $property) { - continue; - } + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - if (!$this->accessor->isReadable($subject, $property)) { - continue; - } + $result = true; + + if ($condition['entity'] !== $subject->getOwner()) { + $result = false; + } + + 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 (!$this->accessor->getValue($subject, $property) !== $value) { + if (!in_array($comparison, ['eq', 'neq'], 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; + } + + $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; + } + + $result = true; + + if ('' !== $path) { + $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; + } + } + + 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 ('and' === $operator && !in_array(false, $results, true)) { + // All results must be true. + return true; } - if (in_array($attribute, $permission->getAttributes(), true)) { + if ('or' === $operator && in_array(true, $results, true)) { + // At least one result must be true. return true; } } diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 0e39c27..4a22da2 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -119,117 +119,190 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - $scope = $permission->getScope(); - $type = array_key_exists('type', $scope) ? $scope['type'] : null; - $entity = array_key_exists('entity', $scope) ? $scope['entity'] : null; - $entityUuid = array_key_exists('entity_uuid', $scope) ? $scope['entity_uuid'] : null; - - switch ($type) { - case 'generic': - // Nothing to specifically validate. - break; - - case 'object': - if (!$subject instanceof Uuidentifiable) { - // Skip permissions with scope "object" if the subject entity is not uuidentitiable. - continue; - } + if (!in_array($attribute, $permission->getAttributes(), true)) { + // Skip permissions that do not contain the required attribute. + continue; + } - if ($entityUuid !== $subject->getUuid()) { - // Skip permissions that do not match the subject entity uuid. - continue; - } + $operator = $permission->getScopeOperator(); + $conditions = $permission->getScopeConditions(); + $results = []; - break; + foreach ($conditions as $condition) { + $result = null; + $type = isset($condition['type']) ? $condition['type'] : null; - case 'identity': - if (!$subject[0] instanceof Identitiable) { - // Skip permissions with scope "identity" if the subject entity is not identitiable. - continue; - } + switch ($type) { + case 'generic': + // Nothing to specifically validate. + $result = true; + break; - if (null !== $entity) { - if ($entity !== $subject[0]->getIdentity()) { - // Skip permissions that do not match the subject entity identity. + case 'object': + if (!$subject[0] instanceof Uuidentifiable) { + // Skip permissions with scope "object" if the subject entity is not uuidentitiable. continue; } - } - if (null !== $entityUuid) { - if ($entityUuid !== $subject[0]->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. continue; } - } - break; + $result = true; - case 'owner': - if (!$subject[0] instanceof Ownable) { - // Skip permissions with scope "owner" if the subject entity is not ownable. - continue; - } + if ($condition['entity_uuid'] !== $subject[0]->getUuid()) { + $result = false; + } + + break; - if (null !== $entity) { - if ($entity !== $subject[0]->getOwner()) { - // Skip permissions that do not match the subject entity owner. + case 'identity': + if (!$subject[0] instanceof Identitiable) { + // Skip permissions with scope "identity" if the subject entity is not identitiable. continue; } - } - if (null !== $entityUuid) { - if ($entityUuid !== $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; + if (!isset($condition['entity_uuid'])) { + // Skip permissions without entity_uuid defined. + continue; + } - 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()) { - // Skip permissions that do not match the subject entity identity. - continue; - } + if ($condition['entity'] !== $subject[0]->getIdentity()) { + $result = false; + } - if ($user->getIdentity()->getUuid() !== $subject->getIdentityUuid()) { - // Skip permissions that do not match the subject entity identity uuid. - continue; - } + if ($condition['entity_uuid'] !== $subject[0]->getIdentityUuid()) { + $result = false; + } - break; + break; - case 'property': - $property = array_key_exists('property', $scope) ? $scope['property'] : null; - $value = array_key_exists('value', $scope) ? $scope['value'] : null; + case 'owner': + if (!$subject[0] instanceof Ownable) { + // Skip permissions with scope "owner" if the subject entity is not ownable. + continue; + } - if (null === $property) { - continue; - } + if (!isset($condition['entity'])) { + // Skip permissions without entity defined. + continue; + } - if (!$this->accessor->isReadable($subject, $property)) { - continue; - } + $result = true; + + if ($condition['entity'] !== $subject[0]->getOwner()) { + $result = false; + } + + 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 (!$this->accessor->getValue($subject, $property) !== $value) { + if (!in_array($comparison, ['eq', 'neq'], 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; + } + + $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; + } + + $result = true; + + if ('' !== $path) { + $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; + } + } + + 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 ('and' === $operator && !in_array(false, $results, true)) { + // All results must be true. + return true; } - if (in_array($attribute, $permission->getAttributes(), true)) { + if ('or' === $operator && in_array(true, $results, true)) { + // At least one result must be true. return true; } } From 4c2371640e087c2490231085d8df7f9109e9bda3 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 2 Aug 2020 07:53:30 -0400 Subject: [PATCH 03/29] Synchronize migration with doctrine metadata --- src/Acl/Migration/Version0_19_0.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Acl/Migration/Version0_19_0.php b/src/Acl/Migration/Version0_19_0.php index c474ad8..0acc00a 100644 --- a/src/Acl/Migration/Version0_19_0.php +++ b/src/Acl/Migration/Version0_19_0.php @@ -33,6 +33,9 @@ public function up(Schema $schema) 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'); From d04780ac0b11abb648c670adc2bc23d052b450d9 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 10:29:51 -0400 Subject: [PATCH 04/29] Convert business units role association into a broader entity uuids role association --- src/Acl/Service/AccessService.php | 58 ++++++--- src/Api/Api/Api.php | 1 + src/Api/Model/AnonymousRole.php | 4 +- src/Api/Model/IndividualRole.php | 4 +- src/Api/Model/OrganizationRole.php | 4 +- src/Api/Model/Staff.php | 3 + src/Api/Model/StaffRole.php | 4 +- src/Api/Model/SystemRole.php | 4 +- src/Api/Service/AnonymousRoleService.php | 2 +- src/Api/Service/IndividualRoleService.php | 2 +- src/Api/Service/OrganizationRoleService.php | 2 +- src/Api/Service/StaffRoleService.php | 2 +- src/Api/Service/StaffService.php | 1 + src/Api/Service/SystemRoleService.php | 2 +- src/Model/Attribute/Accessor/EntityUuids.php | 34 ++++++ src/Model/Attribute/EntityUuids.php | 18 +++ .../Token/Identity/BusinessUnitsListener.php | 113 ++++++++++++++++++ .../Token/Identity/RolesListener.php | 7 +- .../Attribute/Accessor/BusinessUnits.php | 35 ++++++ .../Model/Attribute/BusinessUnits.php | 18 +++ src/Security/Model/Identity.php | 10 ++ src/Security/Model/User.php | 1 + .../Test/Collection/UserCollection.php | 2 +- .../DependencyInjection/Configuration.php | 2 + .../Test/Resources/config/config.yaml | 10 ++ 25 files changed, 306 insertions(+), 37 deletions(-) create mode 100644 src/Model/Attribute/Accessor/EntityUuids.php create mode 100644 src/Model/Attribute/EntityUuids.php create mode 100644 src/Security/EventListener/Token/Identity/BusinessUnitsListener.php create mode 100644 src/Security/Model/Attribute/Accessor/BusinessUnits.php create mode 100644 src/Security/Model/Attribute/BusinessUnits.php diff --git a/src/Acl/Service/AccessService.php b/src/Acl/Service/AccessService.php index dbeab04..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; - // Identity wide 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) } } - // Identity specific 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) } } - // Role permissions + // Permissions assigned to a role. $roles = $user->getIdentity()->getRoles(); $accesses = $this->repository->findBy([ @@ -87,25 +87,53 @@ public function getPermissions(User $user, bool $cache = false) foreach ($access->getPermissions() as $permission) { $scope = $permission->getScope(); - if ( - array_key_exists('entity_uuid', $scope) - && '*' === $scope['entity_uuid'] - ) { - if ( - array_key_exists('type', $scope) - && 'owner' === $scope['type'] - && array_key_exists('entity', $scope) - && 'BusinessUnit' === $scope['entity'] - ) { - 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; $cloneScope = $clone->getScope(); - $cloneScope['entity_uuid'] = $businessUnit; + + 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/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/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/Staff.php b/src/Api/Model/Staff.php index 82741ae..4a4d6e8 100644 --- a/src/Api/Model/Staff.php +++ b/src/Api/Model/Staff.php @@ -3,6 +3,7 @@ namespace Ds\Component\Api\Model; use Ds\Component\Model\Attribute; +use Ds\Component\Api\Model\Attribute as ApiAttribute; /** * Class Staff @@ -18,6 +19,7 @@ final class Staff implements Model use Attribute\Owner; use Attribute\OwnerUuid; use Attribute\Roles; + use ApiAttribute\BusinessUnits; use Attribute\Version; use Attribute\Tenant; @@ -27,5 +29,6 @@ final class Staff implements Model public function __construct() { $this->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/Service/AnonymousRoleService.php b/src/Api/Service/AnonymousRoleService.php index 7c166a3..fb10164 100644 --- a/src/Api/Service/AnonymousRoleService.php +++ b/src/Api/Service/AnonymousRoleService.php @@ -36,7 +36,7 @@ final class AnonymousRoleService implements Service 'ownerUuid', 'anonymous', 'role', - 'businessUnits', + 'entityUuids', 'version', 'tenant' ]; 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/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/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 @@ +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..450a7d2 100644 --- a/src/Security/EventListener/Token/Identity/RolesListener.php +++ b/src/Security/EventListener/Token/Identity/RolesListener.php @@ -103,12 +103,7 @@ public function created(JWTCreatedEvent $event) } foreach ($identityRoles as $identityRole) { - $role = $identityRole->getRole()->getUuid(); - $roles[$role] = []; - - foreach ($identityRole->getBusinessUnits() as $businessUnit) { - $roles[$role][] = $businessUnit->getUuid(); - } + $roles[$identityRole->getRole()->getUuid()] = $identityRole->getEntityUuids(); } } } 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..4e7f4e6 100644 --- a/src/Security/Model/User.php +++ b/src/Security/Model/User.php @@ -22,6 +22,7 @@ public static function createFromPayload($username, array $payload) $roles = $payload['roles'] ?? []; $identity = new Identity; $identity->setRoles((array) $payload['identity']->roles ?? []); + $identity->setBusinessUnits((array) $payload['identity']->business_units ?? []); $identity->setType($payload['identity']->type ?? null); $identity->setUuid($payload['identity']->uuid ?? null); $tenant = $payload['tenant'] ?? null; diff --git a/src/Security/Test/Collection/UserCollection.php b/src/Security/Test/Collection/UserCollection.php index ef53e7c..10b61d8 100644 --- a/src/Security/Test/Collection/UserCollection.php +++ b/src/Security/Test/Collection/UserCollection.php @@ -91,7 +91,7 @@ protected function cast($element) { throw new InvalidArgumentException('Element is not an array.'); } - foreach (['username', 'roles', 'uuid', 'identity', 'tenant'] as $key) { + foreach (['username', 'roles', 'business_units', 'uuid', 'identity', 'tenant'] as $key) { if (!array_key_exists($key, $element)) { throw new InvalidArgumentException('Element is missing key "'.$key.'".'); } 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..62f2ba4 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,7 @@ 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 From 71631e9fd93a1c9c4af38ec2419428c460a0d03e Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 14:24:06 -0400 Subject: [PATCH 05/29] Add feature toggler for token identity business units field --- src/Security/DependencyInjection/Configuration.php | 3 +++ src/Security/DependencyInjection/DsSecurityExtension.php | 2 ++ 2 files changed, 5 insertions(+) diff --git a/src/Security/DependencyInjection/Configuration.php b/src/Security/DependencyInjection/Configuration.php index 882f99e..1103ece 100644 --- a/src/Security/DependencyInjection/Configuration.php +++ b/src/Security/DependencyInjection/Configuration.php @@ -37,6 +37,9 @@ public function getConfigTreeBuilder() ->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 From b033855e4b79e2e7b8ce4467af4ec3503b141795 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 14:25:11 -0400 Subject: [PATCH 06/29] Fix isset check regarding user creation from jwt token payload --- src/Security/Model/User.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Security/Model/User.php b/src/Security/Model/User.php index 4e7f4e6..ad2bb65 100644 --- a/src/Security/Model/User.php +++ b/src/Security/Model/User.php @@ -21,10 +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->setBusinessUnits((array) $payload['identity']->business_units ?? []); - $identity->setType($payload['identity']->type ?? null); - $identity->setUuid($payload['identity']->uuid ?? null); + $identity->setRoles((array) array_key_exists('identity', $payload) && property_exists($payload['identity'], 'roles') ? $payload['identity']->roles : []); + $identity->setBusinessUnits((array) array_key_exists('identity', $payload) && property_exists($payload['identity'], 'business_units') ? $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); From d45efdc8524ee469c9320bc41c0e6368e16704ad Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 16:28:52 -0400 Subject: [PATCH 07/29] Add business unit role api service and related resources --- .../Model/Attribute/Accessor/BusinessUnit.php | 36 +++++++++ src/Api/Model/Attribute/BusinessUnit.php | 18 +++++ src/Api/Model/BusinessUnitRole.php | 35 +++++++++ src/Api/Query/Attribute/BusinessUnitUuid.php | 47 +++++++++++ src/Api/Query/BusinessUnitRoleParameters.php | 16 ++++ .../Resources/config/services/identities.yaml | 5 ++ src/Api/Service/Base.php | 6 ++ src/Api/Service/BusinessUnitRoleService.php | 78 +++++++++++++++++++ 8 files changed, 241 insertions(+) create mode 100644 src/Api/Model/Attribute/Accessor/BusinessUnit.php create mode 100644 src/Api/Model/Attribute/BusinessUnit.php create mode 100644 src/Api/Model/BusinessUnitRole.php create mode 100644 src/Api/Query/Attribute/BusinessUnitUuid.php create mode 100644 src/Api/Query/BusinessUnitRoleParameters.php create mode 100644 src/Api/Service/BusinessUnitRoleService.php 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/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; 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; + } +} From f2a5b5972e56266978e44bfde920ed726357739a Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 16:29:31 -0400 Subject: [PATCH 08/29] Add business unit roles to jwt token roles --- .../Token/Identity/RolesListener.php | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/src/Security/EventListener/Token/Identity/RolesListener.php b/src/Security/EventListener/Token/Identity/RolesListener.php index 450a7d2..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,7 +104,49 @@ public function created(JWTCreatedEvent $event) } foreach ($identityRoles as $identityRole) { - $roles[$identityRole->getRole()->getUuid()] = $identityRole->getEntityUuids(); + $uuid = $identityRole->getRole()->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; + } + } } } } From e9bfff6a6011625d2e4dc3a05e43ea6777af8f66 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Mon, 3 Aug 2020 17:08:22 -0400 Subject: [PATCH 09/29] Remove attribute from the wrong array level --- src/Security/Test/Collection/UserCollection.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Security/Test/Collection/UserCollection.php b/src/Security/Test/Collection/UserCollection.php index 10b61d8..ef53e7c 100644 --- a/src/Security/Test/Collection/UserCollection.php +++ b/src/Security/Test/Collection/UserCollection.php @@ -91,7 +91,7 @@ protected function cast($element) { throw new InvalidArgumentException('Element is not an array.'); } - foreach (['username', 'roles', 'business_units', 'uuid', 'identity', 'tenant'] as $key) { + foreach (['username', 'roles', 'uuid', 'identity', 'tenant'] as $key) { if (!array_key_exists($key, $element)) { throw new InvalidArgumentException('Element is missing key "'.$key.'".'); } From 7ffdb4a9d467390862d0bd223447893e5b206df0 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Wed, 5 Aug 2020 18:47:43 -0400 Subject: [PATCH 10/29] Refactor api permission scope property. --- src/Api/Model/Attribute/Scope.php | 18 ------------- src/Api/Model/Permission.php | 4 +-- src/Api/Model/Scope.php | 18 ------------- src/Api/Service/Base.php | 9 ------- src/Model/Attribute/Accessor/Scope.php | 35 ++++++++++++++++++++++++++ src/Model/Attribute/Scope.php | 18 +++++++++++++ 6 files changed, 55 insertions(+), 47 deletions(-) delete mode 100644 src/Api/Model/Attribute/Scope.php delete mode 100644 src/Api/Model/Scope.php create mode 100644 src/Model/Attribute/Accessor/Scope.php create mode 100644 src/Model/Attribute/Scope.php diff --git a/src/Api/Model/Attribute/Scope.php b/src/Api/Model/Attribute/Scope.php deleted file mode 100644 index 49f8fd3..0000000 --- a/src/Api/Model/Attribute/Scope.php +++ /dev/null @@ -1,18 +0,0 @@ -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 @@ -$remote = [ - 'type' => $value->getType(), - 'entity' => $value->getEntity(), - 'entityUuid' => $value->getEntityUuid() - ]; - - break; - default: $object->$remote = $value; } 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/Scope.php b/src/Model/Attribute/Scope.php new file mode 100644 index 0000000..f12933c --- /dev/null +++ b/src/Model/Attribute/Scope.php @@ -0,0 +1,18 @@ + Date: Thu, 6 Aug 2020 17:34:02 -0400 Subject: [PATCH 11/29] Fix type-casting of jwt payload --- src/Security/Model/User.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Security/Model/User.php b/src/Security/Model/User.php index ad2bb65..429661f 100644 --- a/src/Security/Model/User.php +++ b/src/Security/Model/User.php @@ -21,8 +21,8 @@ public static function createFromPayload($username, array $payload) $uuid = $payload['uuid'] ?? null; $roles = $payload['roles'] ?? []; $identity = new Identity; - $identity->setRoles((array) array_key_exists('identity', $payload) && property_exists($payload['identity'], 'roles') ? $payload['identity']->roles : []); - $identity->setBusinessUnits((array) array_key_exists('identity', $payload) && property_exists($payload['identity'], 'business_units') ? $payload['identity']->business_units : []); + $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; From 0b52f28faed58825252d37917c2cbcde362093c8 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 10:06:34 -0400 Subject: [PATCH 12/29] Add tenant 0 jwt tokens for tests --- .../Test/Resources/config/config.yaml | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/Security/Test/Resources/config/config.yaml b/src/Security/Test/Resources/config/config.yaml index 62f2ba4..84ac47c 100644 --- a/src/Security/Test/Resources/config/config.yaml +++ b/src/Security/Test/Resources/config/config.yaml @@ -99,3 +99,53 @@ ds_security_test: 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 From 3598ccdb1f4275acf7c25fbceef0cd036e787f65 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 10:27:19 -0400 Subject: [PATCH 13/29] Fix yaml tabbing --- .../Test/Resources/config/config.yaml | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/src/Security/Test/Resources/config/config.yaml b/src/Security/Test/Resources/config/config.yaml index 84ac47c..3326d44 100644 --- a/src/Security/Test/Resources/config/config.yaml +++ b/src/Security/Test/Resources/config/config.yaml @@ -100,52 +100,52 @@ ds_security_test: 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: + - username: system@system.ds roles: [] - business_units: [] - type: System - uuid: 36315b58-1502-43e9-bb9c-ebe49ff321ab - tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + 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: + - username: staff@staff.ds roles: [] - business_units: [] - type: Staff - uuid: 7878154e-cda3-48a4-95cf-71bc4b2cc3ba - tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + 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: + - username: organization@organization.ds roles: [] - business_units: [] - type: Organization - uuid: 31f65051-399d-4f2f-ab40-5ae19e03ff70 - tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + 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: + - username: individual@individual.ds roles: [] - business_units: [] - type: Individual - uuid: 1d897239-763f-4e90-9bf8-6976431e36cd - tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + 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: + - username: anonymous@anonymous.ds roles: [] - business_units: [] - type: Anonymous - uuid: e0b59434-be1e-407d-b70c-fc2b52ebb4b5 - tenant: 64c82518-017d-4fb2-9fcf-3926da3616e6 # Tenant 0 + 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 From 0a194ec84dce2b444cac5bc733fdc6ed4e4230ff Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 10:44:42 -0400 Subject: [PATCH 14/29] Add user with role option to behat authenticated given statement --- src/Security/Test/Context/UserContext.php | 25 ++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Security/Test/Context/UserContext.php b/src/Security/Test/Context/UserContext.php index ab81c56..143d3fb 100644 --- a/src/Security/Test/Context/UserContext.php +++ b/src/Security/Test/Context/UserContext.php @@ -46,7 +46,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 +65,27 @@ 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 role from a tenant + * + * @Given I am authenticated as the :username user with role :role from the tenant :tenant + * @param string $username + * @param string $role + * @param string $tenant + */ + public function iAmAuthenticatedAsTheUserWithRoleFromTheTenant(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.'); + } + + $user['roles'] = [$role]; + $token = $this->tokenManager->create($user); + $this->request->setHttpHeader('Authorization', 'Bearer '.$token); + } } From a7194015ba26a36077974ef9c4658b844feeb2bb Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 15:08:54 -0400 Subject: [PATCH 15/29] Add reflection to behat context --- src/Security/Model/User.php | 8 ++++++++ src/Security/Test/Context/UserContext.php | 18 +++++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/Security/Model/User.php b/src/Security/Model/User.php index 429661f..4d13716 100644 --- a/src/Security/Model/User.php +++ b/src/Security/Model/User.php @@ -133,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 143d3fb..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 @@ -67,14 +69,14 @@ public function iAmAuthenticatedAsTheUserFromTheTenant(string $username, string } /** - * Set authorization header with a given user and role from a tenant + * Set authorization header with a given user and identity role from a tenant * - * @Given I am authenticated as the :username user with role :role from the tenant :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 iAmAuthenticatedAsTheUserWithRoleFromTheTenant(string $username, string $role, 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; @@ -84,8 +86,14 @@ public function iAmAuthenticatedAsTheUserWithRoleFromTheTenant(string $username, throw new DomainException('User "'.$username.'" for tenant "'.$tenant.'" does not exist.'); } - $user['roles'] = [$role]; - $token = $this->tokenManager->create($user); + $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); } } From 91023be402ba6c6f2b0a1816f0bed5045775c039 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 15:47:15 -0400 Subject: [PATCH 16/29] Fix acl voters array vs object typecast with scalar translations --- src/Acl/Voter/EntityVoter.php | 65 ++++++++++++++++++++++++++++++++- src/Acl/Voter/PropertyVoter.php | 65 ++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/src/Acl/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index d4b6a0e..b81658d 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,9 @@ 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; @@ -34,6 +38,11 @@ final class EntityVoter extends Voter */ private $entityCollection; + /** + * @var \Doctrine\Common\Annotations\Reader + */ + private $annotationReader; + /** * @var \Symfony\Component\PropertyAccess\PropertyAccessor */ @@ -44,11 +53,13 @@ final class EntityVoter extends Voter * * @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(); } @@ -237,10 +248,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } + $field = $this->getField(get_class($subject), $property); $result = true; if ('' !== $path) { - $property .= '.' . $path; + if ('translation.scalar' === $field) { + $property .= '[' . $path . ']'; + } else { + $property .= '.' . $path; + } } if (!$this->accessor->isReadable($subject, $property)) { @@ -289,4 +305,49 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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 4a22da2..0bf64e4 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,9 @@ 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; @@ -34,6 +38,11 @@ final class PropertyVoter extends Voter */ private $entityCollection; + /** + * @var \Doctrine\Common\Annotations\Reader + */ + private $annotationReader; + /** * @var \Symfony\Component\PropertyAccess\PropertyAccessor */ @@ -44,11 +53,13 @@ final class PropertyVoter extends Voter * * @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(); } @@ -257,10 +268,15 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } + $field = $this->getField(get_class($subject[0]), $property); $result = true; if ('' !== $path) { - $property .= '.' . $path; + if ('translation.scalar' === $field) { + $property .= '[' . $path . ']'; + } else { + $property .= '.' . $path; + } } if (!$this->accessor->isReadable($subject[0], $property)) { @@ -309,4 +325,49 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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'; + } } From 3ac6aafc05e9f9493ae0d28c9d4a8a6a8a048b53 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 16:31:40 -0400 Subject: [PATCH 17/29] Fix path regarding array vs object for nested json properties --- .../Doctrine/ORM/QueryExtension/EntityExtension.php | 12 ++++++------ src/Acl/Voter/EntityVoter.php | 2 ++ src/Acl/Voter/PropertyVoter.php | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index 15306c2..a208cc5 100644 --- a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php +++ b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php @@ -315,12 +315,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator if (null === $value) { if ('eq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); } else if ('neq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')'); + $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); } } else { - $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $translationAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')', ':ds_security_property_' . $i); $parameters['ds_security_property_' . $i] = $value; } } else if ('json' === $field) { @@ -333,12 +333,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator if (null === $value) { if ('eq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + $subWheres[] = $queryBuilder->expr()->isNull('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); } else if ('neq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')'); + $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); } } else { - $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_TEXT(' . $rootAlias . '.' . $property . ', \'' . $path . '\')', ':ds_security_property_' . $i); + $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')', ':ds_security_property_' . $i); $parameters['ds_security_property_' . $i] = $value; } } else if ('scalar' === $field) { diff --git a/src/Acl/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index b81658d..0f3a4a8 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -254,6 +254,8 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) if ('' !== $path) { if ('translation.scalar' === $field) { $property .= '[' . $path . ']'; + } else if ('json' === $field) { + $property .= '[' . str_replace('.', '][', $path) . ']'; } else { $property .= '.' . $path; } diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 0bf64e4..800111d 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -274,6 +274,8 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) if ('' !== $path) { if ('translation.scalar' === $field) { $property .= '[' . $path . ']'; + } else if ('json' === $field) { + $property .= '[' . str_replace('.', '][', $path) . ']'; } else { $property .= '.' . $path; } From 9d74194d6f31eab5312e13bc7af9846a438e5bdb Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 17:38:44 -0400 Subject: [PATCH 18/29] Add like comparison to acl property scope --- .../ORM/QueryExtension/EntityExtension.php | 59 +++++++++++++++---- src/Acl/Voter/EntityVoter.php | 14 ++++- src/Acl/Voter/PropertyVoter.php | 14 ++++- 3 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index a208cc5..9b64bb2 100644 --- a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php +++ b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php @@ -270,7 +270,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator continue; } - if (!in_array($comparison, ['eq', 'neq'], true)) { + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { // Skip permissions that do not have supported comparison types. continue; } @@ -280,6 +280,11 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator 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)); @@ -302,7 +307,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } } else { $subWheres[] = $queryBuilder->expr()->{$comparison}($translationAlias . '.' . $property, ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } } } else if ('translation.json' === $field) { if ('' === $path) { @@ -313,15 +323,26 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); $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('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); + $subWheres[] = $queryBuilder->expr()->isNull($operand); } else if ('neq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); + $subWheres[] = $queryBuilder->expr()->isNotNull($operand); } } else { - $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_PATH_TEXT(' . $translationAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')', ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; + $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 ('' === $path) { @@ -331,15 +352,26 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $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('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); + $subWheres[] = $queryBuilder->expr()->isNull($operand); } else if ('neq' === $comparison) { - $subWheres[] = $queryBuilder->expr()->isNotNull('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')'); + $subWheres[] = $queryBuilder->expr()->isNotNull($operand); } } else { - $subWheres[] = $queryBuilder->expr()->{$comparison}('JSON_GET_PATH_TEXT(' . $rootAlias . '.' . $property . ', \'{' . str_replace('.', ', ', $path) . '}\')', ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; + $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 ('' !== $path) { @@ -355,7 +387,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } } else { $subWheres[] = $queryBuilder->expr()->{$comparison}($rootAlias . '.' . $property, ':ds_security_property_' . $i); - $parameters['ds_security_property_' . $i] = $value; + + if ('like' === $comparison) { + $parameters['ds_security_property_' . $i] = '%' . $value . '%'; + } else { + $parameters['ds_security_property_' . $i] = $value; + } } } diff --git a/src/Acl/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index 0f3a4a8..ee0d9bb 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -229,7 +229,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (!in_array($comparison, ['eq', 'neq'], true)) { + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { // Skip permissions that do not have supported comparison types. continue; } @@ -239,6 +239,11 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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)); @@ -273,6 +278,13 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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; diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 800111d..147b983 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -249,7 +249,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) continue; } - if (!in_array($comparison, ['eq', 'neq'], true)) { + if (!in_array($comparison, ['eq', 'neq', 'like'], true)) { // Skip permissions that do not have supported comparison types. continue; } @@ -259,6 +259,11 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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)); @@ -293,6 +298,13 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) 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; From 43e1155fae8e6610964893b0fe520de8848abd3a Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 Aug 2020 21:10:59 -0400 Subject: [PATCH 19/29] Add specific locale to acl query building --- .../ORM/QueryExtension/EntityExtension.php | 34 +++++++++++++------ src/Acl/Voter/EntityVoter.php | 2 +- src/Acl/Voter/PropertyVoter.php | 2 +- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php index 9b64bb2..57b668b 100644 --- a/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php +++ b/src/Acl/Doctrine/ORM/QueryExtension/EntityExtension.php @@ -287,7 +287,6 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $parts = explode('.', $property); $property = array_shift($parts); - $path = str_replace('\'', '', implode('.', $parts)); if (!property_exists($resourceClass, $property)) { // Skip permissions that do not specify an existing property on the entity. @@ -297,7 +296,14 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $field = $this->getField($resourceClass, $property); if ('translation.scalar' === $field) { - $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + 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) { @@ -315,12 +321,15 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } } } else if ('translation.json' === $field) { - if ('' === $path) { - // Skip permissions that do not specify json path. + if (count($parts) !== 2) { + // Skip permissions that do not specify a language and a json path. continue; } - $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass); + $locale = array_shift($parts); + $path = implode('.', $parts); + $translationAlias = $this->addJoinTranslation($queryBuilder, $resourceClass, $locale, $i); + $i++; $value = $this->typeCast($value); if (false !== strpos($path, '.')) { @@ -345,11 +354,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } } } else if ('json' === $field) { - if ('' === $path) { + if (count($parts) !== 1) { // Skip permissions that do not specify json path. continue; } + $path = implode('.', $parts); $value = $this->typeCast($value); if (false !== strpos($path, '.')) { @@ -374,7 +384,7 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } } } else if ('scalar' === $field) { - if ('' !== $path) { + if (count($parts) !== 0) { // Skip permissions that do not specify an existing property on the entity. continue; } @@ -474,12 +484,14 @@ private function getField($resourceClass, $property): ?string * * @param QueryBuilder $queryBuilder * @param string $resourceClass + * @param string $locale + * @param integer $i * @return string */ - private function addJoinTranslation(QueryBuilder $queryBuilder, string $resourceClass): string + private function addJoinTranslation(QueryBuilder $queryBuilder, string $resourceClass, string $locale, int $i): string { $rootAlias = $queryBuilder->getRootAliases()[0]; - $translationAlias = $rootAlias . '_t'; + $translationAlias = $rootAlias . '_t_' . $i; $parts = $queryBuilder->getDQLParts()['join']; foreach ($parts as $joins) { @@ -490,7 +502,9 @@ private function addJoinTranslation(QueryBuilder $queryBuilder, string $resource } } - $queryBuilder->innerJoin($rootAlias . '.translations', $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; } diff --git a/src/Acl/Voter/EntityVoter.php b/src/Acl/Voter/EntityVoter.php index ee0d9bb..970f83c 100644 --- a/src/Acl/Voter/EntityVoter.php +++ b/src/Acl/Voter/EntityVoter.php @@ -259,7 +259,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) if ('' !== $path) { if ('translation.scalar' === $field) { $property .= '[' . $path . ']'; - } else if ('json' === $field) { + } else if ('json' === $field || 'translation.json' === $field) { $property .= '[' . str_replace('.', '][', $path) . ']'; } else { $property .= '.' . $path; diff --git a/src/Acl/Voter/PropertyVoter.php b/src/Acl/Voter/PropertyVoter.php index 147b983..01a08d2 100644 --- a/src/Acl/Voter/PropertyVoter.php +++ b/src/Acl/Voter/PropertyVoter.php @@ -279,7 +279,7 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) if ('' !== $path) { if ('translation.scalar' === $field) { $property .= '[' . $path . ']'; - } else if ('json' === $field) { + } else if ('json' === $field || 'translation.json' === $field) { $property .= '[' . str_replace('.', '][', $path) . ']'; } else { $property .= '.' . $path; From 4d12bb64b1e2a931a1c65c9639e14cd3bdef30f5 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 23 Aug 2020 11:58:50 -0400 Subject: [PATCH 20/29] Enable camunda api component to filter by multiple candidate groups --- .../Query/Attribute/CandidateGroups.php | 49 +++++++++++++++++++ src/Camunda/Query/Base.php | 4 ++ src/Camunda/Query/TaskParameters.php | 2 +- 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 src/Camunda/Query/Attribute/CandidateGroups.php 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/Base.php b/src/Camunda/Query/Base.php index caf63b8..e8cd5a8 100644 --- a/src/Camunda/Query/Base.php +++ b/src/Camunda/Query/Base.php @@ -52,6 +52,10 @@ public function toObject(bool $minimal = false) $object->$key = implode(',', $value); break; + case 'candidateGroups': + $object->$key = implode(',', $value); + break; + case 'createdBefore': case 'createdAfter': case 'dueBefore': diff --git a/src/Camunda/Query/TaskParameters.php b/src/Camunda/Query/TaskParameters.php index 68db6ea..e058847 100644 --- a/src/Camunda/Query/TaskParameters.php +++ b/src/Camunda/Query/TaskParameters.php @@ -14,7 +14,7 @@ final class TaskParameters implements Parameters use Attribute\TaskIdIn; use Attribute\Assignee; use Attribute\AssigneeLike; - use Attribute\CandidateGroup; + use Attribute\CandidateGroups; use Attribute\IncludeAssignedTasks; use Attribute\CreatedBefore; use Attribute\CreatedAfter; From 124ae7b8fd0bb6a550175483511b6dbcd7ee84c7 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 30 Aug 2020 11:03:53 -0400 Subject: [PATCH 21/29] Enable write acl on created_at entity properties --- src/Acl/Entity/Access.php | 5 +++-- src/Association/Entity/Association.php | 5 +++-- src/Config/Entity/Config.php | 5 +++-- src/Metadata/Entity/Metadata.php | 5 +++-- src/Tenant/Entity/Tenant.php | 5 +++-- 5 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Acl/Entity/Access.php b/src/Acl/Entity/Access.php index 0a0b680..2b07fc9 100644 --- a/src/Acl/Entity/Access.php +++ b/src/Acl/Entity/Access.php @@ -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/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/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/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/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; From 322526ce267eec2fd08068760d67c333f420ac35 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 30 Aug 2020 11:08:00 -0400 Subject: [PATCH 22/29] Add created_at and updated_at config acl permissions --- src/Config/Resources/config/packages/ds_acl.yaml | 2 ++ 1 file changed, 2 insertions(+) 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 } From c762a4f7a9ed1815742564d7890f8652d65dc3d0 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 30 Aug 2020 11:24:07 -0400 Subject: [PATCH 23/29] Update fixtures related to created_at properties --- src/Acl/Fixture/Access.php | 8 ++++++++ src/Config/Fixture/Config.php | 8 ++++++++ src/Metadata/Fixture/Metadata.php | 8 ++++++++ src/Tenant/Fixture/Tenant.php | 8 ++++++++ 4 files changed, 32 insertions(+) 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/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/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/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); } From a2ee64b80ace952fc48f6f7e2c7674de74410d81 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Tue, 1 Sep 2020 20:26:57 -0400 Subject: [PATCH 24/29] Map unassigned camunda query parameter to tasks endpoint --- src/Camunda/Query/Attribute/Unassigned.php | 47 ++++++++++++++++++++++ src/Camunda/Query/Base.php | 4 +- src/Camunda/Query/TaskParameters.php | 1 + 3 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 src/Camunda/Query/Attribute/Unassigned.php 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 e8cd5a8..58fc850 100644 --- a/src/Camunda/Query/Base.php +++ b/src/Camunda/Query/Base.php @@ -45,13 +45,11 @@ public function toObject(bool $minimal = false) break; case 'cascade': + case 'unassigned': $object->$key = $value ? 'true' : 'false'; break; case 'tenantIdIn': - $object->$key = implode(',', $value); - break; - case 'candidateGroups': $object->$key = implode(',', $value); break; diff --git a/src/Camunda/Query/TaskParameters.php b/src/Camunda/Query/TaskParameters.php index e058847..30ca1b5 100644 --- a/src/Camunda/Query/TaskParameters.php +++ b/src/Camunda/Query/TaskParameters.php @@ -14,6 +14,7 @@ final class TaskParameters implements Parameters use Attribute\TaskIdIn; use Attribute\Assignee; use Attribute\AssigneeLike; + use Attribute\Unassigned; use Attribute\CandidateGroups; use Attribute\IncludeAssignedTasks; use Attribute\CreatedBefore; From ddd320c3aacb7c5e7bdb962b5d2715abcb1dd9d2 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Wed, 9 Sep 2020 22:07:58 -0400 Subject: [PATCH 25/29] Further map camunda api component --- src/Camunda/Query/Base.php | 16 +++++++++-- src/Camunda/Query/Parameters.php | 3 +- src/Camunda/Service/TaskService.php | 43 +++++++++++++++++++++++------ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/Camunda/Query/Base.php b/src/Camunda/Query/Base.php index 58fc850..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; @@ -46,12 +46,22 @@ public function toObject(bool $minimal = false) case 'cascade': case 'unassigned': - $object->$key = $value ? 'true' : 'false'; + if ('query' === $type) { + $object->$key = $value ? 'true' : 'false'; + } else { + $object->$key = $value; + } + break; case 'tenantIdIn': case 'candidateGroups': - $object->$key = implode(',', $value); + 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/Service/TaskService.php b/src/Camunda/Service/TaskService.php index be35018..dae2220 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,24 @@ public function getList(Parameters $parameters = null) } $resource = substr($resource, 0, -1); + $objects = $this->execute('GET', $resource, $options); } 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; + } + } + } + + $objects = $this->execute('POST', $resource, $options); } - $objects = $this->execute('GET', $resource, $options); $list = []; foreach ($objects as $object) { @@ -111,17 +124,31 @@ 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; + } + } + } + + $result = $this->execute('POST', $resource, $options); return $result->count; } From eb6d93c6b97786367b2ae7795bd5d2460345aacb Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Thu, 17 Sep 2020 19:44:29 -0400 Subject: [PATCH 26/29] Fix camunda integration tasks pagination --- src/Camunda/Service/TaskService.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Camunda/Service/TaskService.php b/src/Camunda/Service/TaskService.php index dae2220..895e4e5 100644 --- a/src/Camunda/Service/TaskService.php +++ b/src/Camunda/Service/TaskService.php @@ -107,6 +107,13 @@ public function getList(Parameters $parameters = null, array $orParameters = []) } } + 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); } @@ -148,6 +155,13 @@ public function getCount(Parameters $parameters = null, array $orParameters = [] } } + 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; From 88c93f489c389062f4a06708a5166addec5d1c05 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sat, 21 Nov 2020 08:05:32 -0500 Subject: [PATCH 27/29] Add created and due asc/desc filters on tasksByUuids camunda tasks service integration --- src/Camunda/Service/TaskService.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/Camunda/Service/TaskService.php b/src/Camunda/Service/TaskService.php index 895e4e5..c43f636 100644 --- a/src/Camunda/Service/TaskService.php +++ b/src/Camunda/Service/TaskService.php @@ -93,6 +93,28 @@ public function getList(Parameters $parameters = null, array $orParameters = []) $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); + } + } + } } else { $resource = static::RESOURCE_LIST; $options['json'] = $query; From 1eb165985b721340965462d5fb236c937a66e63b Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Wed, 25 Nov 2020 18:10:32 -0500 Subject: [PATCH 28/29] Add pagination filters to custom task search endpoint for camunda --- src/Camunda/Service/TaskService.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Camunda/Service/TaskService.php b/src/Camunda/Service/TaskService.php index c43f636..1d9e473 100644 --- a/src/Camunda/Service/TaskService.php +++ b/src/Camunda/Service/TaskService.php @@ -115,6 +115,14 @@ public function getList(Parameters $parameters = null, array $orParameters = []) } } } + + 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['json'] = $query; From 0a131afa1d7b9ceaadae50c131c2855005dd0f06 Mon Sep 17 00:00:00 2001 From: marioprudhomme Date: Sun, 9 May 2021 14:07:30 -0400 Subject: [PATCH 29/29] Disable pagination for task-by-ids custom camunda endpoint --- src/Camunda/Service/TaskService.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Camunda/Service/TaskService.php b/src/Camunda/Service/TaskService.php index 1d9e473..c0f5240 100644 --- a/src/Camunda/Service/TaskService.php +++ b/src/Camunda/Service/TaskService.php @@ -116,13 +116,13 @@ public function getList(Parameters $parameters = null, array $orParameters = []) } } - if (array_key_exists('firstResult', $query) && array_key_exists('maxResults', $query)) { - $objects = array_slice( - $objects, - $query['firstResult'], - $query['maxResults'] - ); - } +// 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['json'] = $query;