From 4a8b50c6ee8fdb0fe0c7de51a264d76c99b378b9 Mon Sep 17 00:00:00 2001
From: Tobias Gross-Vogt <tobias.gross-vogt@tugraz.at>
Date: Thu, 9 Feb 2023 12:58:14 +0100
Subject: [PATCH] #25859 abstract normalizer that allows configuring access
 exrpressions for entity attributes

---
 .../Serializer/AbstractEntityNormalizer.php   | 146 ++++++++++++++++++
 src/Helpers/ApiPlatformHelperFunctions.php    |  37 +++++
 src/LocalData/LocalDataEventDispatcher.php    |  20 +--
 3 files changed, 185 insertions(+), 18 deletions(-)
 create mode 100644 src/Authorization/Serializer/AbstractEntityNormalizer.php
 create mode 100644 src/Helpers/ApiPlatformHelperFunctions.php

diff --git a/src/Authorization/Serializer/AbstractEntityNormalizer.php b/src/Authorization/Serializer/AbstractEntityNormalizer.php
new file mode 100644
index 0000000..2861f18
--- /dev/null
+++ b/src/Authorization/Serializer/AbstractEntityNormalizer.php
@@ -0,0 +1,146 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Authorization\Serializer;
+
+use Dbp\Relay\CoreBundle\Authorization\AbstractAuthorizationService;
+use Dbp\Relay\CoreBundle\Exception\ApiError;
+use Dbp\Relay\CoreBundle\Helpers\ApiPlatformHelperFunctions;
+use Symfony\Component\Config\Definition\Builder\NodeDefinition;
+use Symfony\Component\Config\Definition\Builder\TreeBuilder;
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
+
+abstract class AbstractEntityNormalizer extends AbstractAuthorizationService implements ContextAwareNormalizerInterface, NormalizerAwareInterface
+{
+    use NormalizerAwareTrait;
+
+    private const ROOT_CONFIG_NODE = 'attribute_access';
+
+    private const ENTITY_SHORT_NAME_KEY = 'short_name';
+    private const ATTRIBUTE_NAMES_KEY = 'attribute_names';
+
+    private const ENTITY_OBJECT_ALIAS = 'entity';
+
+    /** @var Security */
+    private $security;
+
+    /** @var array */
+    private $entityClassNames;
+
+    /** @var array */
+    private $entityClassNameToAttributeNamesMapping;
+
+    public static function getAttributeAccessConfigNodeDefinition(array $entityShortNameToAttributeNamesMapping): NodeDefinition
+    {
+        $treeBuilder = new TreeBuilder(self::ROOT_CONFIG_NODE);
+
+        foreach ($entityShortNameToAttributeNamesMapping as $entityShortName => $attributeNames) {
+            $attributeNodeBuilder = $treeBuilder->getRootNode()->children()->arrayNode($entityShortName)
+                ->addDefaultsIfNotSet()
+                ->children();
+            foreach ($attributeNames as $attributeName) {
+                $attributeNodeBuilder->scalarNode($attributeName)
+                    ->defaultValue('false')
+                    ->info(sprintf('viewer role expression for attribute \'%s\' under entity \'%s\'', $attributeName, $entityShortName))
+                    ->end();
+            }
+        }
+
+        return $treeBuilder->getRootNode();
+    }
+
+    private static function toAttributeId(string $entityShortName, string $attributeName): string
+    {
+        return $entityShortName.':'.$attributeName;
+    }
+
+    private static function getUniqueAlreadyCalledKeyForEntity(string $entityClassName): string
+    {
+        return self::class.$entityClassName;
+    }
+
+    protected function __construct(array $entityClassNames)
+    {
+        $this->entityClassNames = $entityClassNames;
+    }
+
+    public function setConfig(array $config)
+    {
+        $configNode = $config[self::ROOT_CONFIG_NODE] ?? [];
+        $rightExpressions = [];
+
+        foreach ($this->entityClassNames as $entityClassName) {
+            $entityShortName = ApiPlatformHelperFunctions::getShortNameForResource($entityClassName);
+            $entityNode = $configNode[$entityShortName] ?? null;
+            if ($entityNode === null) {
+                throw ApiError::withDetails(Response::HTTP_INTERNAL_SERVER_ERROR, sprintf('attribute access not configured for entity \'%s\'', $entityShortName));
+            }
+
+            $attributeNames = [];
+            foreach ($entityNode as $attributeName => $attributeAuthorizationExpression) {
+                $rightExpressions[self::toAttributeId($entityShortName, $attributeName)] = $attributeAuthorizationExpression;
+                $attributeNames[] = $attributeName;
+            }
+            $this->entityClassNameToAttributeNamesMapping[$entityClassName] = [
+                self::ENTITY_SHORT_NAME_KEY => $entityShortName,
+                self::ATTRIBUTE_NAMES_KEY => $attributeNames,
+            ];
+        }
+
+        parent::setConfig(parent::createConfig($rightExpressions));
+    }
+
+    /**
+     * @required
+     */
+    public function __inject(Security $security)
+    {
+        $this->security = $security;
+    }
+
+    /**
+     *  {@inheritDoc}
+     */
+    public function normalize($object, $format = null, array $context = [])
+    {
+        $entityClassName = get_class($object);
+        $mapEntry = $this->entityClassNameToAttributeNamesMapping[$entityClassName];
+        $entityShortName = $mapEntry[self::ENTITY_SHORT_NAME_KEY];
+
+        foreach ($mapEntry[self::ATTRIBUTE_NAMES_KEY] as $attributeName) {
+            $attributeId = self::toAttributeId($entityShortName, $attributeName);
+            dump($attributeId);
+            if ($this->isGranted($attributeId, $object, self::ENTITY_OBJECT_ALIAS)) {
+                $context['groups'][] = $attributeId;
+            }
+        }
+
+        $context[self::getUniqueAlreadyCalledKeyForEntity($entityClassName)] = true;
+
+        return $this->normalizer->normalize($object, $format, $context);
+    }
+
+    /**
+     *  {@inheritDoc}
+     */
+    public function supportsNormalization($data, $format = null, array $context = []): bool
+    {
+        if (!is_object($data)) {
+            return false;
+        }
+
+        $entityClassName = get_class($data);
+
+        // Make sure we're not called twice
+        if (isset($context[self::getUniqueAlreadyCalledKeyForEntity($entityClassName)])) {
+            return false;
+        }
+
+        return array_key_exists($entityClassName, $this->entityClassNameToAttributeNamesMapping);
+    }
+}
diff --git a/src/Helpers/ApiPlatformHelperFunctions.php b/src/Helpers/ApiPlatformHelperFunctions.php
new file mode 100644
index 0000000..bfa5c74
--- /dev/null
+++ b/src/Helpers/ApiPlatformHelperFunctions.php
@@ -0,0 +1,37 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Helpers;
+
+use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
+use ApiPlatform\Core\Metadata\Resource\Factory\AnnotationResourceMetadataFactory;
+use Dbp\Relay\CoreBundle\Exception\ApiError;
+use Doctrine\Common\Annotations\AnnotationReader;
+
+class ApiPlatformHelperFunctions
+{
+    /**
+     * Returns the 'shortName' attribute of the ApiResource annotation of the entity with the given class name.
+     *
+     * @throws ApiError if the ApiResource annotation of $resourceClass is not found or doesn't have a non-empty 'shortName' attribute
+     */
+    public static function getShortNameForResource(string $resourceClass): string
+    {
+        $resourceMetadataFactory = new AnnotationResourceMetadataFactory(new AnnotationReader());
+        try {
+            $resourceMetadata = $resourceMetadataFactory->create($resourceClass);
+        } catch (ResourceClassNotFoundException $exc) {
+            throw new ApiError(500, $exc->getMessage());
+        }
+
+        $uniqueName = $resourceMetadata->getShortName() ?? '';
+        if (Tools::isNullOrEmpty($uniqueName)) {
+            throw new ApiError(500, sprintf("'shortName' attribute missing in ApiResource annotation of resource class '%s'", $resourceClass));
+        } elseif (str_contains($uniqueName, '.') || str_contains($uniqueName, ',')) {
+            throw new ApiError(500, sprintf("'shortName' attribute of resource class '%s' must not contain '.' or ',' characters: '%s'", $resourceClass, $uniqueName));
+        }
+
+        return $uniqueName;
+    }
+}
diff --git a/src/LocalData/LocalDataEventDispatcher.php b/src/LocalData/LocalDataEventDispatcher.php
index 93f7d92..a3140ea 100644
--- a/src/LocalData/LocalDataEventDispatcher.php
+++ b/src/LocalData/LocalDataEventDispatcher.php
@@ -4,11 +4,9 @@ declare(strict_types=1);
 
 namespace Dbp\Relay\CoreBundle\LocalData;
 
-use ApiPlatform\Core\Exception\ResourceClassNotFoundException;
-use ApiPlatform\Core\Metadata\Resource\Factory\AnnotationResourceMetadataFactory;
 use Dbp\Relay\CoreBundle\Exception\ApiError;
+use Dbp\Relay\CoreBundle\Helpers\ApiPlatformHelperFunctions;
 use Dbp\Relay\CoreBundle\Helpers\Tools;
-use Doctrine\Common\Annotations\AnnotationReader;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\HttpFoundation\Response;
 use Symfony\Contracts\EventDispatcher\Event;
@@ -111,21 +109,7 @@ class LocalDataEventDispatcher
      */
     public static function getUniqueEntityName(string $resourceClass): string
     {
-        $resourceMetadataFactory = new AnnotationResourceMetadataFactory(new AnnotationReader());
-        try {
-            $resourceMetadata = $resourceMetadataFactory->create($resourceClass);
-        } catch (ResourceClassNotFoundException $exc) {
-            throw new ApiError(500, $exc->getMessage());
-        }
-
-        $uniqueName = $resourceMetadata->getShortName() ?? '';
-        if (Tools::isNullOrEmpty($uniqueName)) {
-            throw new ApiError(500, sprintf("'shortName' attribute missing in ApiResource annotation of resource class '%s'", $resourceClass));
-        } elseif (str_contains($uniqueName, '.') || str_contains($uniqueName, ',')) {
-            throw new ApiError(500, sprintf("'shortName' attribute of resource class '%s' must not contain '.' or ',' characters: '%s'", $resourceClass, $uniqueName));
-        }
-
-        return $uniqueName;
+        return ApiPlatformHelperFunctions::getShortNameForResource($resourceClass);
     }
 
     /**
-- 
GitLab