From 6190de8bc684b2b7ce2852aeecf722b1ef66f339 Mon Sep 17 00:00:00 2001
From: Tobias Gross-Vogt <tobias.gross-vogt@tugraz.at>
Date: Mon, 20 Feb 2023 08:50:01 +0100
Subject: [PATCH] entity attribute authorization now part of the
 AbstractAuthorizationService and config is found under
 'authorization.entities

---
 .../AbstractAuthorizationService.php          | 226 +++++++++++++++---
 .../AuthorizationExpressionChecker.php        |  32 +--
 src/Helpers/Tools.php                         |   9 +
 .../AbstractLocalDataEventSubscriber.php      |   4 +-
 4 files changed, 216 insertions(+), 55 deletions(-)

diff --git a/src/Authorization/AbstractAuthorizationService.php b/src/Authorization/AbstractAuthorizationService.php
index 2d3e209..4944f0a 100644
--- a/src/Authorization/AbstractAuthorizationService.php
+++ b/src/Authorization/AbstractAuthorizationService.php
@@ -6,13 +6,39 @@ namespace Dbp\Relay\CoreBundle\Authorization;
 
 use Dbp\Relay\CoreBundle\API\UserSessionInterface;
 use Dbp\Relay\CoreBundle\Exception\ApiError;
+use Dbp\Relay\CoreBundle\Helpers\Tools;
 use Symfony\Component\Config\Definition\Builder\NodeDefinition;
 use Symfony\Component\Config\Definition\Builder\TreeBuilder;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait;
 
-abstract class AbstractAuthorizationService
+abstract class AbstractAuthorizationService implements ContextAwareNormalizerInterface, NormalizerAwareInterface
 {
+    use NormalizerAwareTrait;
+
+    /* config array keys */
     private const AUTHORIZATION_ROOT_CONFIG_NODE = 'authorization';
+    private const ROLES_CONFIG_NODE = 'roles';
+    private const ATTRIBUTES_CONFIG_NODE = 'attributes';
+    private const ENTITIES_CONFIG_NODE = 'entities';
+    private const ENTITY_READ_ACCESS_CONFIG_NODE = 'read_access';
+    private const ENTITY_WRITE_ACCESS_CONFIG_NODE = 'write_access';
+    private const ENTITY_CLASS_NAME_CONFIG_NODE = 'class_name';
+
+    /* internal array keys */
+    private const ROLES_KEY = 'roles';
+    private const ATTRIBUTES_KEY = 'attributes';
+    private const ENTITIES_KEY = 'entities';
+    private const ENTITY_READ_ACCESS_KEY = 'read_access';
+    private const ENTITY_WRITE_ACCESS_KEY = 'write_access';
+    private const ENTITY_SHORT_NAME_KEY = 'short_name';
+    private const ENTITY_CLASS_NAME_KEY = 'class_name';
+
+    private const ENTITY_READ_ACCESS_ATTRIBUTE_NAMES_KEY = 'read_attribute_names';
+    private const ENTITY_OBJECT_ALIAS = 'entity';
+    private const CONTEXT_GROUPS_KEY = 'groups';
 
     /** @var AuthorizationExpressionChecker */
     private $userAuthorizationChecker;
@@ -23,6 +49,14 @@ abstract class AbstractAuthorizationService
     /** @var array|null */
     private $config;
 
+    /** @var array */
+    private $entityClassNameToAttributeNamesMapping;
+
+    public function __construct()
+    {
+        $this->entityClassNameToAttributeNamesMapping = [];
+    }
+
     /**
      * @required
      */
@@ -30,20 +64,22 @@ abstract class AbstractAuthorizationService
     {
         $this->userAuthorizationChecker = new AuthorizationExpressionChecker($mux);
         $this->currentAuthorizationUser = new AuthorizationUser($userSession, $this->userAuthorizationChecker);
-        $this->updateConfig();
+        $this->loadConfig();
     }
 
     public function setConfig(array $config)
     {
         $this->config = $config[self::AUTHORIZATION_ROOT_CONFIG_NODE] ?? [];
-        $this->updateConfig();
+        $this->loadConfig();
     }
 
-    private function updateConfig()
+    public function configure(array $roleMapping = [], array $attributeMapping = []): void
     {
-        if ($this->userAuthorizationChecker !== null && $this->config !== null) {
-            $this->userAuthorizationChecker->setConfig($this->config);
-        }
+        $this->config = [
+                self::ROLES_CONFIG_NODE => $roleMapping,
+                self::ATTRIBUTES_CONFIG_NODE => $attributeMapping,
+        ];
+        $this->loadConfig();
     }
 
     /**
@@ -76,6 +112,80 @@ abstract class AbstractAuthorizationService
         return $this->getAttributeInternal($attributeName, $defaultValue);
     }
 
+    /**
+     *  {@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::ENTITY_READ_ACCESS_ATTRIBUTE_NAMES_KEY] as $attributeName) {
+            $attributeId = self::toAttributeId($entityShortName, $attributeName);
+            if ($this->isGranted($attributeId, $object, self::ENTITY_OBJECT_ALIAS)) {
+                $context[self::CONTEXT_GROUPS_KEY][] = $attributeId;
+            }
+        }
+
+        $context[self::getUniqueAlreadyCalledKeyForEntity($entityClassName)] = true;
+
+        return $this->normalizer->normalize($object, $format, $context);
+    }
+
+    /**
+     *  {@inheritDoc}
+     */
+    public function supportsNormalization($data, $format = null, array $context = []): bool
+    {
+        if ($this->entityClassNameToAttributeNamesMapping === null || is_object($data) === false) {
+            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);
+    }
+
+    private function loadConfig()
+    {
+        if ($this->userAuthorizationChecker !== null && $this->config !== null) {
+            $roleExpressions = $this->config[self::ROLES_CONFIG_NODE] ?? [];
+            $attributeExpressions = $this->config[self::ATTRIBUTES_CONFIG_NODE] ?? [];
+
+            if (isset($this->config[self::ENTITIES_CONFIG_NODE])) {
+                $entitiesRoleExpressions = $this->loadEntityConfig($this->config[self::ENTITIES_CONFIG_NODE]);
+                $roleExpressions = array_merge($roleExpressions, $entitiesRoleExpressions);
+            }
+
+            $this->userAuthorizationChecker->setExpressions($roleExpressions, $attributeExpressions);
+        }
+    }
+
+    private function loadEntityConfig(array $entitiesConfigNode): array
+    {
+        $roleExpressions = [];
+        foreach ($entitiesConfigNode as $entityShortName => $entityNode) {
+            $entityClassName = $entityNode[self::ENTITY_CLASS_NAME_CONFIG_NODE];
+            $attributeNames = [];
+            foreach ($entityNode[self::ENTITY_READ_ACCESS_CONFIG_NODE] ?? [] as $attributeName => $attributeAuthorizationExpression) {
+                $roleExpressions[self::toAttributeId($entityShortName, $attributeName)] = $attributeAuthorizationExpression;
+                $attributeNames[] = $attributeName;
+            }
+            $this->entityClassNameToAttributeNamesMapping[$entityClassName] = [
+                self::ENTITY_SHORT_NAME_KEY => $entityShortName,
+                self::ENTITY_READ_ACCESS_ATTRIBUTE_NAMES_KEY => $attributeNames,
+            ];
+        }
+
+        return $roleExpressions;
+    }
+
     private function getAttributeInternal(string $attributeName, $defaultValue = null)
     {
         return $this->userAuthorizationChecker->evalAttributeExpression($this->currentAuthorizationUser, $attributeName, $defaultValue);
@@ -90,47 +200,101 @@ abstract class AbstractAuthorizationService
     }
 
     /**
-     * Create the 'authorization' config node definition with the given right and attribute definitions.
-     * A definition is an array of the following form:
-     * [0 => <nameString>, 1 => <defaultExpressionString> (optional, default: 'false'), 2 => <infoString> (optional, default: 'null')].
-     *
-     * @param array[] $rights     the list of right definitions
-     * @param array[] $attributes the list of attribute definitions
+     * Create the 'authorization' config node definition with the given config definition.
      */
-    public static function getAuthorizationConfigNodeDefinition(array $rights = [], array $attributes = []): NodeDefinition
+    public static function getAuthorizationConfigNodeDefinition(array $configDefinition): NodeDefinition
     {
         $treeBuilder = new TreeBuilder(self::AUTHORIZATION_ROOT_CONFIG_NODE);
 
-        $rightsNodeChildBuilder = $treeBuilder->getRootNode()->children()->arrayNode(AuthorizationExpressionChecker::ROLES_CONFIG_NODE)
+        $rightsNodeChildBuilder = $treeBuilder->getRootNode()->children()->arrayNode(self::ROLES_CONFIG_NODE)
             ->addDefaultsIfNotSet()
             ->children();
-        foreach ($rights as $right) {
-            $rightsNodeChildBuilder->scalarNode($right[0])
-                ->defaultValue($right[1] ?? 'false')
-                ->info($right[2] ?? '')
+        foreach ($configDefinition[self::ROLES_KEY] ?? [] as $roleDefinition) {
+            $rightsNodeChildBuilder->scalarNode($roleDefinition[0])
+                ->defaultValue($roleDefinition[1] ?? 'false')
+                ->info($roleDefinition[2] ?? '')
                 ->end();
         }
 
-        $attributesNodeChildBuilder = $treeBuilder->getRootNode()->children()->arrayNode(AuthorizationExpressionChecker::ATTRIBUTES_CONFIG_NODE)
+        $attributesNodeChildBuilder = $treeBuilder->getRootNode()->children()->arrayNode(self::ATTRIBUTES_CONFIG_NODE)
             ->addDefaultsIfNotSet()
             ->children();
-        foreach ($attributes as $attribute) {
-            $attributesNodeChildBuilder->scalarNode($attribute[0])
-                ->defaultValue($attribute[1] ?? 'null')
-                ->info($attribute[2] ?? '')
+        foreach ($configDefinition[self::ATTRIBUTES_KEY] ?? [] as $attributeDefinition) {
+            $attributesNodeChildBuilder->scalarNode($attributeDefinition[0])
+                ->defaultValue($attributeDefinition[1] ?? 'null')
+                ->info($attributeDefinition[2] ?? '')
                 ->end();
         }
 
+        $entitiesNodeChildBuilder = $treeBuilder->getRootNode()->children()->arrayNode(self::ENTITIES_CONFIG_NODE)
+            ->children();
+        foreach ($configDefinition[self::ENTITIES_KEY] ?? [] as $entityDefinition) {
+            $entityChildBuilder = $entitiesNodeChildBuilder->arrayNode($entityDefinition[self::ENTITY_SHORT_NAME_KEY])
+                ->children();
+            $entityChildBuilder->scalarNode(self::ENTITY_CLASS_NAME_CONFIG_NODE)
+                ->defaultValue($entityDefinition[self::ENTITY_CLASS_NAME_KEY])
+                    ->info('The entity class name. There is no need to change the default value.')
+                    ->end();
+            $entityReadAccessChildBuilder = $entityChildBuilder->arrayNode(self::ENTITY_READ_ACCESS_CONFIG_NODE)
+                ->children();
+            foreach ($entityDefinition[self::ENTITY_READ_ACCESS_KEY] ?? [] as $attributeName) {
+                $entityReadAccessChildBuilder->scalarNode($attributeName)
+                    ->defaultValue('false')
+                    ->info(sprintf('The conditional reader role expression for attribute \'%s\'.', $attributeName))
+                    ->end();
+            }
+
+            $entityWriteAccessChildBuilder = $entitiesNodeChildBuilder->arrayNode(self::ENTITY_WRITE_ACCESS_CONFIG_NODE)
+                ->children();
+            foreach ($entityDefinition[self::ENTITY_WRITE_ACCESS_KEY] ?? [] as $attributeName) {
+                $entityWriteAccessChildBuilder->scalarNode($attributeName)
+                    ->defaultValue('false')
+                    ->info(sprintf('The conditional writer role expression for attribute \'%s\'.', $attributeName))
+                    ->end();
+            }
+        }
+
         return $treeBuilder->getRootNode();
     }
 
-    public static function createConfig(array $rightExpressions = [], array $attributeExpressions = []): array
+    public static function configDefinitionCreate(): array
     {
-        return [
-            AbstractAuthorizationService::AUTHORIZATION_ROOT_CONFIG_NODE => [
-                AuthorizationExpressionChecker::ROLES_CONFIG_NODE => $rightExpressions,
-                AuthorizationExpressionChecker::ATTRIBUTES_CONFIG_NODE => $attributeExpressions,
-            ],
-        ];
+        return [];
+    }
+
+    public static function configDefinitionAddRole(array &$configDefinition, string $roleName, string $defaultExpression = 'false', string $info = ''): array
+    {
+        Tools::pushToSubarray($configDefinition, self::ROLES_KEY, [$roleName, $defaultExpression, $info]);
+
+        return $configDefinition;
+    }
+
+    public static function configDefinitionAddAttribute(array &$configDefinition, string $attributeName, string $defaultExpression = 'false', string $info = ''): array
+    {
+        Tools::pushToSubarray($configDefinition, self::ATTRIBUTES_KEY, [$attributeName, $defaultExpression, $info]);
+
+        return $configDefinition;
+    }
+
+    public static function configDefinitionAddEntity(array &$configDefinition, string $entityShortName, string $entityClassName, array $readAttributes = [], array $writeAttributes = []): array
+    {
+        Tools::pushToSubarray($configDefinition, self::ENTITIES_KEY, [
+            self::ENTITY_SHORT_NAME_KEY => $entityShortName,
+            self::ENTITY_CLASS_NAME_KEY => $entityClassName,
+            self::ENTITY_READ_ACCESS_KEY => $readAttributes,
+            self::ENTITY_WRITE_ACCESS_KEY => $writeAttributes,
+            ]);
+
+        return $configDefinition;
+    }
+
+    private static function toAttributeId(string $entityShortName, string $attributeName): string
+    {
+        return $entityShortName.':'.$attributeName;
+    }
+
+    private static function getUniqueAlreadyCalledKeyForEntity(string $entityClassName): string
+    {
+        return self::class.$entityClassName;
     }
 }
diff --git a/src/Authorization/AuthorizationExpressionChecker.php b/src/Authorization/AuthorizationExpressionChecker.php
index b99ec9f..fe931b1 100644
--- a/src/Authorization/AuthorizationExpressionChecker.php
+++ b/src/Authorization/AuthorizationExpressionChecker.php
@@ -11,9 +11,6 @@ use Dbp\Relay\CoreBundle\ExpressionLanguage\ExpressionLanguage;
  */
 class AuthorizationExpressionChecker
 {
-    public const ROLES_CONFIG_NODE = 'roles';
-    public const ATTRIBUTES_CONFIG_NODE = 'attributes';
-
     private const USER_VARIBLE_NAME = 'user';
     private const DEFAULT_OBJECT_VARIBLE_NAME = 'object';
 
@@ -21,7 +18,7 @@ class AuthorizationExpressionChecker
     private $expressionLanguage;
 
     /** @var array */
-    private $rightExpressions;
+    private $roleExpressions;
 
     /** @var array */
     private $attributeExpressions;
@@ -30,7 +27,7 @@ class AuthorizationExpressionChecker
     private $dataMux;
 
     /** @var array */
-    private $rightExpressionStack;
+    private $roleExpressionStack;
 
     /** @var array */
     private $attributeExpressionStack;
@@ -38,17 +35,17 @@ class AuthorizationExpressionChecker
     public function __construct(AuthorizationDataMuxer $dataMux)
     {
         $this->expressionLanguage = new ExpressionLanguage();
-        $this->rightExpressions = [];
+        $this->roleExpressions = [];
         $this->attributeExpressions = [];
         $this->dataMux = $dataMux;
-        $this->rightExpressionStack = [];
+        $this->roleExpressionStack = [];
         $this->attributeExpressionStack = [];
     }
 
-    public function setConfig(array $config)
+    public function setExpressions(array $roleExpressions, array $attributeExpressions)
     {
-        $this->loadExpressions($config[self::ROLES_CONFIG_NODE] ?? [], $this->rightExpressions);
-        $this->loadExpressions($config[self::ATTRIBUTES_CONFIG_NODE] ?? [], $this->attributeExpressions);
+        $this->roleExpressions = $roleExpressions;
+        $this->attributeExpressions = $attributeExpressions;
     }
 
     /**
@@ -98,13 +95,13 @@ class AuthorizationExpressionChecker
      */
     public function isGranted(AuthorizationUser $currentAuthorizationUser, string $rightName, $object, string $objectAlias = null): bool
     {
-        if (in_array($rightName, $this->rightExpressionStack, true)) {
+        if (in_array($rightName, $this->roleExpressionStack, true)) {
             throw new AuthorizationException(sprintf('infinite loop caused by authorization right expression %s detected', $rightName), AuthorizationException::INFINITE_EXRPESSION_LOOP_DETECTED);
         }
-        array_push($this->rightExpressionStack, $rightName);
+        array_push($this->roleExpressionStack, $rightName);
 
         try {
-            $rightExpression = $this->rightExpressions[$rightName] ?? null;
+            $rightExpression = $this->roleExpressions[$rightName] ?? null;
             if ($rightExpression === null) {
                 throw new AuthorizationException(sprintf('right \'%s\' undefined', $rightName), AuthorizationException::PRIVILEGE_UNDEFINED);
             }
@@ -114,14 +111,7 @@ class AuthorizationExpressionChecker
                 $objectAlias ?? self::DEFAULT_OBJECT_VARIBLE_NAME => $object,
             ]);
         } finally {
-            array_pop($this->rightExpressionStack);
-        }
-    }
-
-    private function loadExpressions(array $expressions, array &$target): void
-    {
-        foreach ($expressions as $name => $expression) {
-            $target[$name] = $expression;
+            array_pop($this->roleExpressionStack);
         }
     }
 }
diff --git a/src/Helpers/Tools.php b/src/Helpers/Tools.php
index c26a426..607da0b 100644
--- a/src/Helpers/Tools.php
+++ b/src/Helpers/Tools.php
@@ -40,4 +40,13 @@ class Tools
     {
         return $str === null || $str === '';
     }
+
+    public static function pushToSubarray(array &$parentArray, $childArrayKey, $value)
+    {
+        if (!isset($parentArray[$childArrayKey])) {
+            $parentArray[$childArrayKey] = [$value];
+        } else {
+            $parentArray[$childArrayKey][] = $value;
+        }
+    }
 }
diff --git a/src/LocalData/AbstractLocalDataEventSubscriber.php b/src/LocalData/AbstractLocalDataEventSubscriber.php
index 97e971d..de6f1be 100644
--- a/src/LocalData/AbstractLocalDataEventSubscriber.php
+++ b/src/LocalData/AbstractLocalDataEventSubscriber.php
@@ -82,9 +82,7 @@ abstract class AbstractLocalDataEventSubscriber extends AbstractAuthorizationSer
             $rightExpressions[$localDataAttributeName] = $configMappingEntry[self::AUTHORIZATION_EXPRESSION_CONFIG_NODE] ?? 'false';
         }
 
-        if (!empty($rightExpressions)) {
-            parent::setConfig(parent::createConfig($rightExpressions));
-        }
+        parent::configure($rightExpressions);
     }
 
     public static function getSubscribedEvents(): array
-- 
GitLab