diff --git a/doc/setup.rst b/doc/setup.rst index 503a0c74..09d57d4c 100644 --- a/doc/setup.rst +++ b/doc/setup.rst @@ -45,6 +45,22 @@ If you already have a valid token, you can use it to authenticate. .. sample:: Setup/token_id.php +Overriding catalog endpoints +---------------------------- + +Normally the endpoint URL for a service is read from the Identity catalog returned by the authentication request. +In some cases that URL is not the one that should be used, for example when an application credential is used to +access an object container that belongs to another project. The ``catalog_overrides`` option lets you replace the +catalog URL for a service. + +Each override must contain a ``url`` and at least one of ``name`` or ``type``. Every key that is specified must +match the requested service, while omitted keys act as wildcards. The ``region`` and ``interface`` keys are +therefore optional and can be used to restrict the override further. + +.. sample:: Setup/catalog_override.php + +If no override matches the requested service, the URL from the catalog is used as usual. + Other options ------------- diff --git a/samples/Setup/catalog_override.php b/samples/Setup/catalog_override.php new file mode 100644 index 00000000..92dd6877 --- /dev/null +++ b/samples/Setup/catalog_override.php @@ -0,0 +1,21 @@ + '{authUrl}', + 'region' => '{region}', + 'user' => [ + 'id' => '{userId}', + 'password' => '{password}' + ], + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'region' => '{region}', + 'interface' => 'public', + 'url' => '{serviceUrl}', + ], + ], +]); diff --git a/src/Identity/v3/Models/Catalog.php b/src/Identity/v3/Models/Catalog.php index d208d7b6..ce5cc37e 100644 --- a/src/Identity/v3/Models/Catalog.php +++ b/src/Identity/v3/Models/Catalog.php @@ -31,6 +31,46 @@ public function populateFromArray(array $data): self return $this; } + /** + * Override a given service's predetermined endpoint URL. + * + * @param string $name the name of the service as it appears in the catalog + * @param string $type the type of the service as it appears in the catalog + * @param string $region the region of the service as it appears in the catalog + * @param string $interface the interface of the service as it appears in the catalog + * @param array $overrides endpoint overrides to match against + * + * @return string|null NULL if no URL found + */ + public function getServiceUrlOverride( + string $name, + string $type, + string $region, + string $interface, + array $overrides + ): ?string { + foreach ($overrides as $override) { + if (empty($override['name']) && empty($override['type'])) { + throw new \RuntimeException('Endpoint override must specify either a "name" or a "type".'); + } + + if (empty($override['url'])) { + throw new \RuntimeException(sprintf("Endpoint override must specify an \"url\".\nName: %s\nType: %s\nRegion: %s\nInterface: %s", $override['name'] ?? '', $override['type'] ?? '', $override['region'] ?? '', $override['interface'] ?? '')); + } + + if ( + (empty($override['name']) || $name == $override['name']) + && (empty($override['type']) || $type == $override['type']) + && (empty($override['region']) || $region == $override['region']) + && (empty($override['interface']) || $interface == $override['interface']) + ) { + return $override['url']; + } + } + + return null; + } + /** * Retrieve a base URL for a service, according to its catalog name, type, region. * diff --git a/src/Identity/v3/Service.php b/src/Identity/v3/Service.php index 5760436f..43e3f86d 100644 --- a/src/Identity/v3/Service.php +++ b/src/Identity/v3/Service.php @@ -47,6 +47,19 @@ public function authenticate(array $options): array $region = $options['region']; $interface = $options['interface'] ?? Enum::INTERFACE_PUBLIC; + if (!empty($options['catalog_overrides'])) { + $baseUrl = $token->catalog->getServiceUrlOverride( + $name, + $type, + $region, + $interface, + $options['catalog_overrides'] + ); + if ($baseUrl) { + return [$token, $baseUrl]; + } + } + if ($baseUrl = $token->catalog->getServiceUrl($name, $type, $region, $interface)) { return [$token, $baseUrl]; } diff --git a/src/OpenStack.php b/src/OpenStack.php index 254e66df..aaa7d2a0 100644 --- a/src/OpenStack.php +++ b/src/OpenStack.php @@ -32,6 +32,7 @@ class OpenStack * ['messageFormatter'] = (MessageFormatter) Must set if debugLog is true [OPTIONAL] * ['requestOptions'] = (array) Guzzle Http request options [OPTIONAL] * ['cachedToken'] = (array) Cached token credential [OPTIONAL] + * ['catalog_overrides'] = (array) Override catalog endpoint URLs [OPTIONAL] */ public function __construct(array $options = [], ?Builder $builder = null) { diff --git a/tests/unit/Identity/v3/Models/CatalogTest.php b/tests/unit/Identity/v3/Models/CatalogTest.php index e499c0bf..41527cfe 100644 --- a/tests/unit/Identity/v3/Models/CatalogTest.php +++ b/tests/unit/Identity/v3/Models/CatalogTest.php @@ -39,6 +39,129 @@ public function test_it_returns_service_url() self::assertEquals($url, $this->catalog->getServiceUrl('foo', 'bar', 'baz', '')); } + public function test_it_returns_service_url_override_matching_name_and_type() + { + $overrides = [ + ['name' => 'swift', 'type' => 'object-store', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_only_name() + { + $overrides = [ + ['name' => 'swift', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_only_type() + { + $overrides = [ + ['type' => 'object-store', 'url' => 'http://override.example'], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_service_url_override_matching_region_and_interface() + { + $overrides = [ + [ + 'name' => 'swift', + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://override.example', + ], + ]; + + self::assertEquals( + 'http://override.example', + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_when_no_override_matches() + { + $overrides = [ + ['name' => 'nova', 'url' => 'http://override.example'], + ]; + + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_when_region_or_interface_does_not_match() + { + $overrides = [ + [ + 'name' => 'swift', + 'region' => 'RegionTwo', + 'interface' => 'internal', + 'url' => 'http://override.example', + ], + ]; + + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides) + ); + } + + public function test_it_returns_null_for_empty_overrides() + { + self::assertNull( + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', []) + ); + } + + public function test_it_throws_when_matching_override_has_neither_name_nor_type() + { + $overrides = [ + ['region' => 'RegionOne', 'url' => 'http://override.example'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify either a "name" or a "type"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + + public function test_it_throws_when_matching_override_has_no_url() + { + $overrides = [ + ['name' => 'swift'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify an "url"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + + public function test_it_throws_when_invalid_override_does_not_match() + { + $overrides = [ + ['name' => 'nova'], + ]; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('must specify an "url"'); + + $this->catalog->getServiceUrlOverride('swift', 'object-store', 'RegionOne', 'public', $overrides); + } + public function test_it_throws_if_no_url_found() { $service = $this->prophesize(Service::class); diff --git a/tests/unit/Identity/v3/ServiceTest.php b/tests/unit/Identity/v3/ServiceTest.php index 420bbf6d..8630244d 100644 --- a/tests/unit/Identity/v3/ServiceTest.php +++ b/tests/unit/Identity/v3/ServiceTest.php @@ -139,6 +139,68 @@ public function test_it_authenticates_using_cache_token() self::assertEquals('http://example.org:8080/v1/AUTH_e00abf65afca49609eedd163c515cf10', $url); } + public function test_it_authenticates_using_catalog_override() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://override.example/v1/AUTH_override', + ], + ], + ]; + + [$token, $url] = $this->service->authenticate($userOptions); + + self::assertInstanceOf(Models\Token::class, $token); + self::assertEquals('http://override.example/v1/AUTH_override', $url); + } + + public function test_it_uses_public_interface_by_default_for_catalog_override() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + [ + 'name' => 'swift', + 'interface' => Enum::INTERFACE_PUBLIC, + 'url' => 'http://override.example/v1/AUTH_override', + ], + ], + ]; + + $result = $this->service->authenticate($userOptions); + + self::assertEquals('http://override.example/v1/AUTH_override', $result[1]); + } + + public function test_it_falls_back_to_catalog_url_when_override_does_not_match() + { + $userOptions = [ + 'catalogName' => 'swift', + 'catalogType' => 'object-store', + 'region' => 'RegionOne', + 'cachedToken' => $this->cachedToken(), + 'catalog_overrides' => [ + ['name' => 'nova', 'url' => 'http://override.example/v1/AUTH_override'], + ], + ]; + + $result = $this->service->authenticate($userOptions); + + self::assertEquals('http://example.org:8080/v1/AUTH_catalog', $result[1]); + } + public function test_it_authenticates_and_throws_exception_when_authenticate_with_expired_cached_token() { $cachedToken = [ @@ -684,4 +746,24 @@ public function test_it_gets_endpoint() { $this->getTest($this->createFn($this->service, 'getEndpoint', 'id'), 'endpoint'); } + + private function cachedToken(): array + { + return [ + 'expires_at' => '2199-11-24T04:47:49.000000Z', + 'catalog' => [ + [ + 'name' => 'swift', + 'type' => 'object-store', + 'endpoints' => [ + [ + 'region' => 'RegionOne', + 'interface' => 'public', + 'url' => 'http://example.org:8080/v1/AUTH_catalog', + ], + ], + ], + ], + ]; + } }