diff --git a/CHANGELOG.md b/CHANGELOG.md index dc5cbfef4..8eca366ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- [#245](https://github.com/os2display/display-api-service/pull/245) + - Added resource endpoint for limiting access to instant book interactive slide. - [#243](https://github.com/os2display/display-api-service/pull/243) - Changed resource name in calendar api feed type resource selector. - [#242](https://github.com/os2display/display-api-service/pull/242) diff --git a/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl b/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl index 7312e7cdc..4491a4df3 100644 --- a/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl +++ b/infrastructure/itkdev/display-api-service/etc/confd/templates/env.local.tmpl @@ -67,3 +67,5 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS={{ getenv "APP_EVENTDATABASE_API_V2_CA TRACK_SCREEN_INFO={{ getenv "APP_TRACK_SCREEN_INFO" "false" }} TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS={{ getenv "APP_TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS" "300" }} + +APP_KEY_VAULT_JSON={{ getenv "APP_KEY_VAULT_JSON" "{}" }} diff --git a/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl b/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl index 2b23b8e25..1fa420ab2 100644 --- a/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl +++ b/infrastructure/os2display/display-api-service/etc/confd/templates/env.local.tmpl @@ -67,3 +67,5 @@ EVENTDATABASE_API_V2_CACHE_EXPIRE_SECONDS={{ getenv "APP_EVENTDATABASE_API_V2_CA TRACK_SCREEN_INFO={{ getenv "APP_TRACK_SCREEN_INFO" "false" }} TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS={{ getenv "APP_TRACK_SCREEN_INFO_UPDATE_INTERVAL_SECONDS" "300" }} + +APP_KEY_VAULT_JSON={{ getenv "APP_KEY_VAULT_JSON" "{}" }} diff --git a/src/InteractiveSlide/InstantBook.php b/src/InteractiveSlide/InstantBook.php index 0ec19002d..62f2f2855 100644 --- a/src/InteractiveSlide/InstantBook.php +++ b/src/InteractiveSlide/InstantBook.php @@ -9,12 +9,12 @@ use App\Entity\Tenant\InteractiveSlide; use App\Entity\Tenant\Slide; use App\Entity\User; -use App\Exceptions\InteractiveSlideException; use App\Service\InteractiveSlideService; use App\Service\KeyVaultService; use Psr\Cache\CacheItemInterface; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\SecurityBundle\Security; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\Security\Core\User\UserInterface; @@ -36,6 +36,7 @@ class InstantBook implements InteractiveSlideInterface private const string SCOPE = 'https://graph.microsoft.com/.default'; private const string GRANT_TYPE = 'password'; private const string CACHE_PREFIX = 'MS-INSTANT-BOOK'; + private const string CACHE_ALLOWED_RESOURCES_PREFIX = 'INSTANT-BOOK-ALLOWED-RESOURCES-'; private const string CACHE_KEY_TOKEN_PREFIX = self::CACHE_PREFIX.'-TOKEN-'; private const string CACHE_KEY_OPTIONS_PREFIX = self::CACHE_PREFIX.'-OPTIONS-'; private const string CACHE_PREFIX_SPAM_PROTECT_PREFIX = self::CACHE_PREFIX.'-SPAM-PROTECT-'; @@ -58,23 +59,29 @@ public function __construct( public function getConfigOptions(): array { + // All secrets are retrieved from the KeyVault. Therefore, the input for the different configurations are the + // keys into the KeyVault where the values can be retrieved. return [ 'tenantId' => [ 'required' => true, - 'description' => 'The key in the KeyVault for the tenant id of the App', + 'description' => 'The key in the KeyVault for the tenant id of the Microsoft Graph App', ], 'clientId' => [ 'required' => true, - 'description' => 'The key in the KeyVault for the client id of the App', + 'description' => 'The key in the KeyVault for the client id of the Microsoft Graph App', ], 'username' => [ 'required' => true, - 'description' => 'The key in the KeyVault for the Microsoft Graph username that should perform the action.', + 'description' => 'The key in the KeyVault for the username that should perform the action.', ], 'password' => [ 'required' => true, 'description' => 'The key in the KeyVault for the password of the user.', ], + 'resourceEndpoint' => [ + 'required' => false, + 'description' => 'The key in the KeyVault for the resources endpoint. This should supply a json list of resources that can be booked. The resources should have ResourceMail and allowInstantBooking ("True"/"False") properties set.', + ], ]; } @@ -83,7 +90,7 @@ public function performAction(UserInterface $user, Slide $slide, InteractionSlid return match ($interactionRequest->action) { self::ACTION_GET_QUICK_BOOK_OPTIONS => $this->getQuickBookOptions($slide, $interactionRequest), self::ACTION_QUICK_BOOK => $this->quickBook($slide, $interactionRequest), - default => throw new InteractiveSlideException('Action not allowed'), + default => throw new BadRequestHttpException('Action not allowed'), }; } @@ -98,7 +105,7 @@ private function authenticate(array $configuration): array $password = $this->keyValueService->getValue($configuration['password']); if (4 !== count(array_filter([$tenantId, $clientId, $username, $password]))) { - throw new \Exception('tenantId, clientId, username, password must all be set.'); + throw new BadRequestHttpException('tenantId, clientId, username, password must all be set.'); } $url = self::LOGIN_ENDPOINT.$tenantId.self::OAUTH_PATH; @@ -124,7 +131,7 @@ private function getToken(Tenant $tenant, InteractiveSlide $interactive): string $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new \Exception('InteractiveNoConfiguration'); + throw new BadRequestHttpException('InteractiveSlide has no configuration'); } return $this->interactiveSlideCache->get( @@ -161,13 +168,16 @@ function (CacheItemInterface $item) use ($slide, $resource, $interactionRequest) $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { - throw new \Exception('InteractiveNotFound'); + throw new \Exception('InteractiveSlide not found'); } + // Optional limiting of available resources. + $this->checkPermission($interactive, $resource); + $feed = $slide->getFeed(); if (null === $feed) { - throw new \Exception('Slide.feed not set.'); + throw new \Exception('Slide feed not set.'); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { @@ -247,7 +257,7 @@ private function createEntry(string $resource, array $schedules, string $startFo */ private function quickBook(Slide $slide, InteractionSlideRequest $interactionRequest): array { - $resource = $this->getValueFromInterval('resource', $interactionRequest); + $resource = (string) $this->getValueFromInterval('resource', $interactionRequest); $durationMinutes = $this->getValueFromInterval('durationMinutes', $interactionRequest); $now = new \DateTime(); @@ -273,17 +283,20 @@ function (CacheItemInterface $item) use ($now): \DateTime { $interactive = $this->interactiveService->getInteractiveSlide($tenant, $interactionRequest->implementationClass); if (null === $interactive) { - throw new \Exception('InteractiveNotFound'); + throw new BadRequestHttpException('Interactive not found'); } + // Optional limiting of available resources. + $this->checkPermission($interactive, $resource); + $feed = $slide->getFeed(); if (null === $feed) { - throw new \Exception('Slide.feed not set.'); + throw new BadRequestHttpException('Slide feed not set.'); } if (!in_array($resource, $feed->getConfiguration()['resources'] ?? [])) { - throw new \Exception('Resource not in feed resources'); + throw new BadRequestHttpException('Resource not in feed resources'); } $token = $this->getToken($tenant, $interactive); @@ -291,7 +304,7 @@ function (CacheItemInterface $item) use ($now): \DateTime { $configuration = $interactive->getConfiguration(); if (null === $configuration) { - throw new \Exception('InteractiveNoConfiguration'); + throw new BadRequestHttpException('Interactive no configuration'); } $username = $this->keyValueService->getValue($configuration['username']); @@ -411,13 +424,13 @@ private function getValueFromInterval(string $key, InteractionSlideRequest $inte $interval = $interactionRequest->data['interval'] ?? null; if (null === $interval) { - throw new \Exception('interval not set.'); + throw new BadRequestHttpException('interval not set.'); } $value = $interval[$key] ?? null; if (null === $value) { - throw new \Exception("interval.'.$key.' not set."); + throw new BadRequestHttpException("interval.'.$key.' not set."); } return $value; @@ -431,4 +444,51 @@ private function getHeaders(string $token): array 'Accept' => 'application/json', ]; } + + private function checkPermission(InteractiveSlide $interactive, string $resource): void + { + $configuration = $interactive->getConfiguration(); + // Optional limiting of available resources. + if (null !== $configuration && !empty($configuration['resourceEndpoint'])) { + $allowedResources = $this->getAllowedResources($interactive); + + if (!in_array($resource, $allowedResources)) { + throw new \Exception('Not allowed'); + } + } + } + + private function getAllowedResources(InteractiveSlide $interactive): array + { + return $this->interactiveSlideCache->get(self::CACHE_ALLOWED_RESOURCES_PREFIX.$interactive->getId(), function (CacheItemInterface $item) use ($interactive) { + $item->expiresAfter(60 * 60); + + $configuration = $interactive->getConfiguration(); + + $key = $configuration['resourceEndpoint'] ?? null; + + if (null === $key) { + throw new \Exception('resourceEndpoint not set'); + } + + $resourceEndpoint = $this->keyValueService->getValue($key); + + if (null === $resourceEndpoint) { + throw new \Exception('resourceEndpoint value not set'); + } + + $response = $this->client->request('GET', $resourceEndpoint); + $content = $response->toArray(); + + $allowedResources = []; + + foreach ($content as $resource) { + if ('True' === $resource['allowInstantBooking']) { + $allowedResources[] = $resource['ResourceMail']; + } + } + + return $allowedResources; + }); + } }