From 1de0ce9286a6559f1e9cf15ac863e72959b86dcb Mon Sep 17 00:00:00 2001
From: Christoph Reiter <reiter.christoph@gmail.com>
Date: Wed, 30 Nov 2022 14:20:16 +0100
Subject: [PATCH] AuthorizationDataMuxer: add two events for mutating
 attributes

One event for changing the global attribute list and one for changing
the result for existing attributes and/or introducing new attributes.
---
 .../AbstractAuthorizationService.php          |  23 ++-
 src/Authorization/AuthorizationDataMuxer.php  | 132 +++++++++++++-----
 .../AuthorizationExpressionChecker.php        |   3 +
 src/Authorization/DebugCommand.php            |  15 +-
 src/Authorization/Event/GetAttributeEvent.php |  89 ++++++++++++
 .../Event/GetAvailableAttributesEvent.php     |  44 ++++++
 .../AbstractLocalDataPostEventSubscriber.php  |   6 +-
 src/Resources/config/services.yaml            |   4 +
 8 files changed, 261 insertions(+), 55 deletions(-)
 create mode 100644 src/Authorization/Event/GetAttributeEvent.php
 create mode 100644 src/Authorization/Event/GetAvailableAttributesEvent.php

diff --git a/src/Authorization/AbstractAuthorizationService.php b/src/Authorization/AbstractAuthorizationService.php
index fc69b1a..28cefd4 100644
--- a/src/Authorization/AbstractAuthorizationService.php
+++ b/src/Authorization/AbstractAuthorizationService.php
@@ -16,19 +16,32 @@ abstract class AbstractAuthorizationService
     /** @var AuthorizationExpressionChecker */
     private $userAuthorizationChecker;
 
-    /** @var AuthorizationUser|null */
+    /** @var AuthorizationUser */
     private $currentAuthorizationUser;
 
-    public function __construct(UserSessionInterface $userSession, AuthorizationDataProviderProvider $authorizationDataProviderProvider)
+    private $config;
+
+    /**
+     * @required
+     */
+    public function _injectServices(UserSessionInterface $userSession, AuthorizationDataMuxer $mux)
     {
-        $muxer = new AuthorizationDataMuxer($authorizationDataProviderProvider->getAuthorizationDataProviders());
-        $this->userAuthorizationChecker = new AuthorizationExpressionChecker($muxer);
+        $this->userAuthorizationChecker = new AuthorizationExpressionChecker($mux);
         $this->currentAuthorizationUser = new AuthorizationUser($userSession->getUserIdentifier(), $this->userAuthorizationChecker);
+        $this->updateConfig();
     }
 
     public function setConfig(array $config)
     {
-        $this->userAuthorizationChecker->setConfig($config);
+        $this->config = $config;
+        $this->updateConfig();
+    }
+
+    private function updateConfig()
+    {
+        if ($this->userAuthorizationChecker !== null && $this->config !== null) {
+            $this->userAuthorizationChecker->setConfig($this->config);
+        }
     }
 
     /**
diff --git a/src/Authorization/AuthorizationDataMuxer.php b/src/Authorization/AuthorizationDataMuxer.php
index 424d35d..f3e0673 100644
--- a/src/Authorization/AuthorizationDataMuxer.php
+++ b/src/Authorization/AuthorizationDataMuxer.php
@@ -4,83 +4,143 @@ declare(strict_types=1);
 
 namespace Dbp\Relay\CoreBundle\Authorization;
 
+use Dbp\Relay\CoreBundle\Authorization\Event\GetAttributeEvent;
+use Dbp\Relay\CoreBundle\Authorization\Event\GetAvailableAttributesEvent;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * @internal
+ */
 class AuthorizationDataMuxer
 {
-    /** @var iterable */
+    /** @var iterable<AuthorizationDataProviderInterface> */
     private $authorizationDataProviders;
 
-    /** @var array */
-    private $attributes;
+    /** @var array<string, array> */
+    private $providerCache;
+
+    /** @var array<string, string[]> */
+    private $availableCache;
+
+    /** @var EventDispatcherInterface */
+    private $eventDispatcher;
+
+    /** @var string[] */
+    private $attributeStack;
 
     /**
-     * @param iterable<AuthorizationDataProviderInterface> $authorizationDataProviders
+     * @var ?string[]
      */
-    public function __construct(iterable $authorizationDataProviders)
+    private $availableCacheAll;
+
+    public function __construct(AuthorizationDataProviderProvider $authorizationDataProviderProvider, EventDispatcherInterface $eventDispatcher)
     {
-        $this->authorizationDataProviders = $authorizationDataProviders;
-        $this->attributes = [];
+        $this->authorizationDataProviders = $authorizationDataProviderProvider->getAuthorizationDataProviders();
+        $this->eventDispatcher = $eventDispatcher;
+        $this->providerCache = [];
+        $this->availableCache = [];
+        $this->availableCacheAll = null;
+        $this->attributeStack = [];
     }
 
-    private function loadUserAttributesFromAuthorizationProvider(?string $userIdentifier, AuthorizationDataProviderInterface $authorizationDataProvider): void
+    /**
+     * Returns an array of available attributes.
+     *
+     * @return string[]
+     */
+    public function getAvailableAttributes(): array
     {
-        $userAttributes = $authorizationDataProvider->getUserAttributes($userIdentifier);
-
-        foreach ($authorizationDataProvider->getAvailableAttributes() as $availableAttribute) {
-            if (array_key_exists($availableAttribute, $userAttributes)) {
-                $this->attributes[$availableAttribute] = $userAttributes[$availableAttribute];
+        if ($this->availableCacheAll === null) {
+            $res = [];
+            foreach ($this->authorizationDataProviders as $authorizationDataProvider) {
+                $availableAttributes = $this->getProviderAvailableAttributes($authorizationDataProvider);
+                $res = array_merge($res, $availableAttributes);
             }
+
+            $event = new GetAvailableAttributesEvent($res);
+            $this->eventDispatcher->dispatch($event);
+            $this->availableCacheAll = $event->getAttributes();
         }
+
+        return $this->availableCacheAll;
     }
 
     /**
-     * Returns an array of available attributes.
+     * Returns a cached list for available attributes for the provider.
      *
      * @return string[]
      */
-    public function getAvailableAttributes(): array
+    private function getProviderAvailableAttributes(AuthorizationDataProviderInterface $prov): array
     {
-        $res = [];
-        foreach ($this->authorizationDataProviders as $authorizationDataProvider) {
-            $availableAttributes = $authorizationDataProvider->getAvailableAttributes();
-            $res = array_merge($res, $availableAttributes);
+        // Caches getAvailableAttributes for each provider
+        $provKey = get_class($prov);
+        if (!array_key_exists($provKey, $this->availableCache)) {
+            $this->availableCache[$provKey] = $prov->getAvailableAttributes();
         }
 
-        return $res;
+        return $this->availableCache[$provKey];
     }
 
     /**
-     * @param mixed|null $defaultValue
+     * Returns a cached map of available user attributes.
      *
-     * @return mixed|null
-     *
-     * @throws AuthorizationException
+     * @return array<string, mixed>
      */
-    public function getAttribute(?string $userIdentifier, string $attributeName, $defaultValue = null)
+    private function getProviderUserAttributes(AuthorizationDataProviderInterface $prov, ?string $userIdentifier): array
     {
-        if (array_key_exists($attributeName, $this->attributes) === false) {
-            $this->loadAttribute($userIdentifier, $attributeName);
+        // We cache the attributes for each provider, but only for the last userIdentifier
+        $provKey = get_class($prov);
+        if (!array_key_exists($provKey, $this->providerCache) || $this->providerCache[$provKey][0] !== $userIdentifier) {
+            $this->providerCache[$provKey] = [$userIdentifier, $prov->getUserAttributes($userIdentifier)];
         }
+        $res = $this->providerCache[$provKey];
+        assert($res[0] === $userIdentifier);
 
-        return $this->attributes[$attributeName] ?? $defaultValue;
+        return $res[1];
     }
 
     /**
+     * @param mixed $defaultValue
+     *
+     * @return mixed
+     *
      * @throws AuthorizationException
      */
-    private function loadAttribute(?string $userIdentifier, string $attributeName): void
+    public function getAttribute(?string $userIdentifier, string $attributeName, $defaultValue = null)
     {
+        if (!in_array($attributeName, $this->getAvailableAttributes(), true)) {
+            throw new AuthorizationException(sprintf('attribute \'%s\' undefined', $attributeName), AuthorizationException::ATTRIBUTE_UNDEFINED);
+        }
+
         $wasFound = false;
+        $value = null;
         foreach ($this->authorizationDataProviders as $authorizationDataProvider) {
-            $availableAttributes = $authorizationDataProvider->getAvailableAttributes();
-            if (in_array($attributeName, $availableAttributes, true)) {
-                $this->loadUserAttributesFromAuthorizationProvider($userIdentifier, $authorizationDataProvider);
-                $wasFound = true;
-                break;
+            $availableAttributes = $this->getProviderAvailableAttributes($authorizationDataProvider);
+            if (!in_array($attributeName, $availableAttributes, true)) {
+                continue;
             }
+            $userAttributes = $this->getProviderUserAttributes($authorizationDataProvider, $userIdentifier);
+            if (!array_key_exists($attributeName, $userAttributes)) {
+                continue;
+            }
+            $value = $userAttributes[$attributeName];
+            $wasFound = true;
+            break;
         }
 
-        if ($wasFound === false) {
-            throw new AuthorizationException(sprintf('custom attribute \'%s\' undefined', $attributeName), AuthorizationException::ATTRIBUTE_UNDEFINED);
+        $event = new GetAttributeEvent($this, $attributeName, $userIdentifier);
+
+        if ($wasFound) {
+            $event->setValue($value);
         }
+
+        // Avoid endless recursions by only emitting an event for each attribtue only once
+        if (!in_array($attributeName, $this->attributeStack, true)) {
+            array_push($this->attributeStack, $attributeName);
+            $this->eventDispatcher->dispatch($event);
+            array_pop($this->attributeStack);
+        }
+
+        return $event->getValue($defaultValue);
     }
 }
diff --git a/src/Authorization/AuthorizationExpressionChecker.php b/src/Authorization/AuthorizationExpressionChecker.php
index 2363d9e..c903ff8 100644
--- a/src/Authorization/AuthorizationExpressionChecker.php
+++ b/src/Authorization/AuthorizationExpressionChecker.php
@@ -6,6 +6,9 @@ namespace Dbp\Relay\CoreBundle\Authorization;
 
 use Dbp\Relay\CoreBundle\Authorization\ExpressionLanguage\ExpressionLanguage;
 
+/**
+ * @internal
+ */
 class AuthorizationExpressionChecker
 {
     public const RIGHTS_CONFIG_ATTRIBUTE = 'rights';
diff --git a/src/Authorization/DebugCommand.php b/src/Authorization/DebugCommand.php
index 619d0ac..174a98e 100644
--- a/src/Authorization/DebugCommand.php
+++ b/src/Authorization/DebugCommand.php
@@ -18,14 +18,14 @@ class DebugCommand extends Command implements LoggerAwareInterface
     protected static $defaultName = 'dbp:relay:core:auth-debug';
 
     /**
-     * @var AuthorizationDataProviderProvider
+     * @var AuthorizationDataMuxer
      */
-    private $provider;
+    private $mux;
 
-    public function __construct(AuthorizationDataProviderProvider $provider)
+    public function __construct(AuthorizationDataMuxer $mux)
     {
         parent::__construct();
-        $this->provider = $provider;
+        $this->mux = $mux;
     }
 
     protected function configure()
@@ -38,15 +38,12 @@ class DebugCommand extends Command implements LoggerAwareInterface
     {
         $username = $input->getArgument('username');
 
-        // Fetch all attributes first (to get potential log spam first)
-        $providers = $this->provider->getAuthorizationDataProviders();
-        $mux = new AuthorizationDataMuxer($providers);
-        $attrs = $mux->getAvailableAttributes();
+        $attrs = $this->mux->getAvailableAttributes();
         $all = [];
         $default = new \stdClass();
         sort($attrs, SORT_STRING | SORT_FLAG_CASE);
         foreach ($attrs as $attr) {
-            $all[$attr] = $mux->getAttribute($username, $attr, $default);
+            $all[$attr] = $this->mux->getAttribute($username, $attr, $default);
         }
 
         // Now print them out
diff --git a/src/Authorization/Event/GetAttributeEvent.php b/src/Authorization/Event/GetAttributeEvent.php
new file mode 100644
index 0000000..d825d52
--- /dev/null
+++ b/src/Authorization/Event/GetAttributeEvent.php
@@ -0,0 +1,89 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Authorization\Event;
+
+use Dbp\Relay\CoreBundle\Authorization\AuthorizationDataMuxer;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * This hook can be used to change the value of attributes at the time they are requested.
+ *
+ * Can be used to change existing attribute values, and introduce new attributes.
+ * In case of new attributes you have to make sure to also handle GetAvailableAttributesEvent
+ * and register your new attribute there.
+ */
+class GetAttributeEvent extends Event
+{
+    /**
+     * @var AuthorizationDataMuxer
+     */
+    private $mux;
+
+    /**
+     * @var ?string
+     */
+    private $userIdentifier;
+
+    /**
+     * @var string
+     */
+    private $name;
+
+    /**
+     * @var mixed
+     */
+    private $value;
+
+    /**
+     * @var bool
+     */
+    private $hasValue;
+
+    public function __construct(AuthorizationDataMuxer $mux, string $name, ?string $userIdentifier)
+    {
+        $this->mux = $mux;
+        $this->userIdentifier = $userIdentifier;
+        $this->name = $name;
+        $this->hasValue = false;
+    }
+
+    /**
+     * @param mixed|null $defaultValue
+     *
+     * @return mixed|null
+     */
+    public function getAttribute(string $attributeName, $defaultValue = null)
+    {
+        return $this->mux->getAttribute($this->userIdentifier, $attributeName, $defaultValue);
+    }
+
+    public function getAttributeName(): string
+    {
+        return $this->name;
+    }
+
+    /**
+     * @param mixed $value
+     */
+    public function setValue($value): void
+    {
+        $this->value = $value;
+        $this->hasValue = true;
+    }
+
+    /**
+     * @param mixed $default
+     *
+     * @return mixed
+     */
+    public function getValue($default = null)
+    {
+        if (!$this->hasValue) {
+            return $default;
+        }
+
+        return $this->value;
+    }
+}
diff --git a/src/Authorization/Event/GetAvailableAttributesEvent.php b/src/Authorization/Event/GetAvailableAttributesEvent.php
new file mode 100644
index 0000000..8cefaa6
--- /dev/null
+++ b/src/Authorization/Event/GetAvailableAttributesEvent.php
@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Authorization\Event;
+
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * This hook can be used to change the set of available attributes.
+ *
+ * You can extend the set, or remove attributes.
+ */
+class GetAvailableAttributesEvent extends Event
+{
+    /**
+     * @var string[]
+     */
+    private $attributes;
+
+    /**
+     * @param string[] $attributes
+     */
+    public function __construct(array $attributes)
+    {
+        $this->attributes = $attributes;
+    }
+
+    /**
+     * @return string[]
+     */
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+
+    /**
+     * @param string[] $attributes
+     */
+    public function setAttributes(array $attributes): void
+    {
+        $this->attributes = $attributes;
+    }
+}
diff --git a/src/LocalData/AbstractLocalDataPostEventSubscriber.php b/src/LocalData/AbstractLocalDataPostEventSubscriber.php
index 9000426..6d6115c 100644
--- a/src/LocalData/AbstractLocalDataPostEventSubscriber.php
+++ b/src/LocalData/AbstractLocalDataPostEventSubscriber.php
@@ -4,9 +4,7 @@ declare(strict_types=1);
 
 namespace Dbp\Relay\CoreBundle\LocalData;
 
-use Dbp\Relay\CoreBundle\API\UserSessionInterface;
 use Dbp\Relay\CoreBundle\Authorization\AbstractAuthorizationService;
-use Dbp\Relay\CoreBundle\Authorization\AuthorizationDataProviderProvider;
 use Symfony\Component\Config\Definition\Builder\TreeBuilder;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
@@ -20,10 +18,8 @@ abstract class AbstractLocalDataPostEventSubscriber extends AbstractAuthorizatio
     /** @var string[] */
     private $attributeMapping;
 
-    public function __construct(UserSessionInterface $userSession, AuthorizationDataProviderProvider $authorizationDataProviderProvider)
+    public function __construct()
     {
-        parent::__construct($userSession, $authorizationDataProviderProvider);
-
         $this->attributeMapping = [];
     }
 
diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml
index 192524e..7f13c44 100644
--- a/src/Resources/config/services.yaml
+++ b/src/Resources/config/services.yaml
@@ -93,6 +93,10 @@ services:
     autoconfigure: true
     arguments: [ !tagged auth.authorization_data_provider ]
 
+  Dbp\Relay\CoreBundle\Authorization\AuthorizationDataMuxer:
+    autowire: true
+    autoconfigure: true
+
   Dbp\Relay\CoreBundle\Helpers\Locale:
     autowire: true
     autoconfigure: true
-- 
GitLab