diff --git a/mistral/context.py b/mistral/context.py index 505e99b4..c9802a3d 100644 --- a/mistral/context.py +++ b/mistral/context.py @@ -13,6 +13,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 + from keystoneclient.v3 import client as keystone_client import logging from oslo_config import cfg @@ -26,12 +28,8 @@ from mistral import auth from mistral import exceptions as exc from mistral import utils - LOG = logging.getLogger(__name__) - - CONF = cfg.CONF - _CTX_THREAD_LOCAL_NAME = "MISTRAL_APP_CTX_THREAD_LOCAL" ALLOWED_WITHOUT_AUTH = ['/', '/v2/'] @@ -78,6 +76,7 @@ class MistralContext(BaseContext): "project_id", "auth_token", "service_catalog", + "target_service_catalog", "user_name", "project_name", "roles", @@ -115,6 +114,7 @@ def context_from_headers_and_env(headers, env): project_id = params['project_id'] user_id = params['user_id'] user_name = params['user_name'] + target_service_catalog = params['target_service_catalog'] token_info = env.get('keystone.token_info') @@ -124,7 +124,7 @@ def context_from_headers_and_env(headers, env): user_id=user_id, project_id=project_id, auth_token=auth_token, - service_catalog=headers.get('X-Service-Catalog'), + target_service_catalog=target_service_catalog, user_name=user_name, project_name=headers.get('X-Project-Name'), roles=headers.get('X-Roles', "").split(","), @@ -134,6 +134,8 @@ def context_from_headers_and_env(headers, env): def _extract_auth_params_from_headers(headers): + target_service_catalog = None + if headers.get("X-Target-Auth-Uri"): params = { # TODO(akovi): Target cert not handled yet @@ -148,6 +150,10 @@ def _extract_auth_params_from_headers(headers): raise (exc.MistralException( 'Target auth URI (X-Target-Auth-Uri) target auth token ' '(X-Target-Auth-Token) must be present')) + + target_service_catalog = _extract_service_catalog_from_headers( + headers + ) else: params = { 'auth_cacert': CONF.keystone_authtoken.cafile, @@ -157,9 +163,23 @@ def _extract_auth_params_from_headers(headers): 'user_id': headers.get('X-User-Id'), 'user_name': headers.get('X-User-Name'), } + + params['target_service_catalog'] = target_service_catalog + return params +def _extract_service_catalog_from_headers(headers): + target_service_catalog_header = headers.get( + 'X-Target-Service-Catalog') + if target_service_catalog_header: + decoded_catalog = base64.b64decode( + target_service_catalog_header).decode() + return jsonutils.loads(decoded_catalog) + else: + return None + + def context_from_config(): keystone = keystone_client.Client( username=CONF.keystone_authtoken.admin_user, diff --git a/mistral/tests/unit/utils/test_keystone_utils.py b/mistral/tests/unit/utils/test_keystone_utils.py index 6e3b9dd7..2d666948 100644 --- a/mistral/tests/unit/utils/test_keystone_utils.py +++ b/mistral/tests/unit/utils/test_keystone_utils.py @@ -16,72 +16,6 @@ from mistral import config from mistral.tests.unit import base from mistral.utils.openstack import keystone -SERVICES_CATALOG = [ - { - "type": "compute", - "name": "nova", - "endpoints": [ - { - "interface": "private", - "url": "https://example.com/nova/private", - "region": "RegionOne" - }, - { - "interface": "public", - "url": "https://example.com/nova/public", - "region": "RegionOne" - }, - { - "interface": "internal", - "url": "https://example.com/nova/internal", - "region": "RegionOne" - } - ] - }, - { - "type": "compute", - "name": "nova2", - "endpoints": [ - { - "interface": "public", - "url": "https://example.com/nova2/public/r1", - "region": "RegionOne" - }, - { - "interface": "public", - "url": "https://example.com/nova2/public/r2", - "region": "RegionTwo" - }, - { - "interface": "internal", - "url": "https://example.com/nova2/internal", - "region": "RegionTwo" - } - ] - }, - { - "type": "orchestration", - "name": "heat", - "endpoints": [ - { - "interface": "private", - "url": "https://example.com/heat/private", - "region": "RegionOne" - }, - { - "interface": "public", - "url": "https://example.com/heat/public", - "region": "RegionOne" - }, - { - "interface": "internal", - "url": "https://example.com/heat/internal", - "region": "RegionTwo" - } - ] - } -] - class KeystoneUtilsTest(base.BaseTest): def setUp(self): @@ -112,43 +46,3 @@ class KeystoneUtilsTest(base.BaseTest): expected, keystone.format_url(url_template, self.values) ) - - def test_service_endpoints_select_default(self): - def find(name, typ=None, catalog=SERVICES_CATALOG): - return keystone.select_service_endpoints(name, typ, catalog) - - endpoints = find('nova', 'compute') - self.assertEqual('https://example.com/nova/public', endpoints[0].url, - message='public interface must be selected') - - endpoints = find('nova2') - self.assertEqual(2, len(endpoints), - message='public endpoints must be selected ' - 'in each region') - - endpoints = find('heat') - self.assertEqual('https://example.com/heat/public', endpoints[0].url, - message='selection should work without type set') - - endpoints = find('nova', None, []) - self.assertEqual([], endpoints, - message='empty catalog should be accepted') - - def test_service_endpoints_select_internal(self): - def find(name, typ=None, catalog=SERVICES_CATALOG): - return keystone.select_service_endpoints(name, typ, catalog) - - self.override_config('os_actions_endpoint_type', 'internal') - endpoints = find('nova', 'compute') - self.assertEqual('https://example.com/nova/internal', endpoints[0].url, - message='internal interface must be selected') - - endpoints = find('nova2') - self.assertEqual("https://example.com/nova2/internal", - endpoints[0].url, - message='internal endpoints must be selected ' - 'in each region') - - endpoints = find('heat') - self.assertEqual('https://example.com/heat/internal', endpoints[0].url, - message='selection should work without type set') diff --git a/mistral/utils/openstack/keystone.py b/mistral/utils/openstack/keystone.py index a677f45a..165ac05b 100644 --- a/mistral/utils/openstack/keystone.py +++ b/mistral/utils/openstack/keystone.py @@ -15,8 +15,9 @@ import keystoneauth1.identity.generic as auth_plugins from keystoneauth1 import session as ks_session +from keystoneclient import service_catalog as ks_service_catalog from keystoneclient.v3 import client as ks_client -from keystoneclient.v3 import endpoints as enp +from keystoneclient.v3 import endpoints as ks_endpoints from oslo_config import cfg from oslo_utils import timeutils @@ -73,9 +74,55 @@ def get_endpoint_for_project(service_name=None, service_type=None): ctx = context.ctx() - token = ctx.auth_token + service_catalog = obtain_service_catalog(ctx) - if (ctx.is_trust_scoped and is_token_trust_scoped(token)): + catalog = service_catalog.get_endpoints( + service_name=service_name, + service_type=service_type + ) + + endpoint = None + for service_type in catalog: + service = catalog.get(service_type) + for interface in service: + # is V3 interface? + if 'interface' in interface: + interface_type = interface['interface'] + if CONF.os_actions_endpoint_type in interface_type: + endpoint = ks_endpoints.Endpoint( + None, + interface, + loaded=True + ) + break + # is V2 interface? + if 'publicURL' in interface: + endpoint_data = { + 'url': interface['publicURL'], + 'region': interface['region'] + } + endpoint = ks_endpoints.Endpoint( + None, + endpoint_data, + loaded=True + ) + break + + if not endpoint: + raise Exception( + "No endpoints found [service_name=%s, service_type=%s]" + % (service_name, service_type) + ) + else: + # TODO(rakhmerov): We may have more than one endpoint because + # TODO(rakhmerov): of regions and ideally we need a config option + # TODO(rakhmerov): for region + return endpoint + + +def obtain_service_catalog(ctx): + token = ctx.auth_token + if ctx.is_trust_scoped and is_token_trust_scoped(token): if ctx.trust_id is None: raise Exception( "'trust_id' must be provided in the admin context." @@ -85,45 +132,16 @@ def get_endpoint_for_project(service_name=None, service_type=None): response = trust_client.tokens.get_token_data( token, include_catalog=True - ) + )['token'] else: - response = client().tokens.get_token_data(token, include_catalog=True) - - endpoints = select_service_endpoints( - service_name, - service_type, - response["token"]["catalog"]) - - if not endpoints: - raise Exception( - "No endpoints found [service_name=%s, service_type=%s]" - % (service_name, service_type) - ) - else: - # TODO(rakhmerov): We may have more than one endpoint because - # TODO(rakhmerov): of regions and ideally we need a config option - # TODO(rakhmerov): for region - return endpoints[0] - - -def select_service_endpoints(service_name, service_type, services): - endpoints = [] - - for catalog in services: - - if service_name and catalog["name"] != service_name: - continue - - if service_type and catalog["type"] != service_type: - continue - - for endpoint in catalog["endpoints"]: - # Keystone v2.0 uses URL while v3 only uses the - # interface without the URL suffix. - if CONF.os_actions_endpoint_type in endpoint["interface"]: - endpoints.append(enp.Endpoint(None, endpoint, loaded=True)) - - return endpoints + if not ctx.target_service_catalog: + response = client().tokens.get_token_data( + token, + include_catalog=True)['token'] + else: + response = ctx.target_service_catalog + service_catalog = ks_service_catalog.ServiceCatalog.factory(response) + return service_catalog def get_keystone_endpoint_v2(): @@ -167,6 +185,8 @@ def get_admin_session(): def will_expire_soon(expires_at): + if not expires_at: + return False stale_duration = CONF.expiration_token_duration assert stale_duration, "expiration_token_duration must be specified" expires = timeutils.parse_isotime(expires_at) diff --git a/mistral_tempest_tests/tests/scenario/engine/actions/v2/test_multi_vim_authentication.py b/mistral_tempest_tests/tests/scenario/engine/actions/v2/test_multi_vim_authentication.py index c6aa77e9..cf112983 100644 --- a/mistral_tempest_tests/tests/scenario/engine/actions/v2/test_multi_vim_authentication.py +++ b/mistral_tempest_tests/tests/scenario/engine/actions/v2/test_multi_vim_authentication.py @@ -11,12 +11,15 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -import json -from mistral_tempest_tests.tests import base -from tempest import test +import base64 from urlparse import urlparse import uuid +from oslo_serialization import jsonutils +from tempest import test + +from mistral_tempest_tests.tests import base + class MultiVimActionsTests(base.TestCase): _service = 'workflowv2' @@ -26,42 +29,95 @@ class MultiVimActionsTests(base.TestCase): super(MultiVimActionsTests, cls).resource_setup() @test.attr(type='openstack') - def test_multi_vim_support(self): - + def test_multi_vim_support_target_headers(self): client_1 = self.alt_client client_2 = self.client - stack_name = 'multi_vim_test_stack_{}'.format(str(uuid.uuid4())[:8]) - create_request = { - 'name': 'heat.stacks_create', - 'input': { - 'stack_name': stack_name, - "template": {"heat_template_version": "2013-05-23"} - } - } - _, body = client_2.create_action_execution(create_request) - stack_id = str(json.loads(body['output'])['result']['stack']['id']) + # Create stack with client2. + result = _execute_action(client_2, _get_create_stack_request()) + stack_id = str( + jsonutils.loads(result['output'])['result']['stack']['id'] + ) - u = urlparse(client_2.auth_provider.auth_url) - v3_auth_url = '{}://{}/identity/v3/'.format(u.scheme, u.netloc) - extra_headers = { - 'X-Target-Auth-Token': client_2.token, - 'X-Target-Auth-Uri': v3_auth_url, - 'X-Target-Project-Id': client_2.tenant_id, - 'X-Target-User-Id': client_2.user_id, - 'X-Target-User-Name': client_2.user, - } + # List stacks with client1, and assert that there is no stack. + result = _execute_action(client_1, _get_list_stack_request()) + self.assertEmpty(jsonutils.loads(result['output'])['result']) - list_request = { - 'name': 'heat.stacks_list', - } - - _, body = client_1.create_action_execution(list_request) - self.assertEmpty(json.loads(body['output'])['result']) - - _, body = client_1.create_action_execution(list_request, - extra_headers=extra_headers) + # List stacks with client1, but with the target headers of client2, + # and assert the created stack is there. + result = _execute_action( + client_1, + _get_list_stack_request(), + extra_headers=_extract_target_headers_from_client(client_2) + ) self.assertEqual( stack_id, - str(json.loads(body['output'])['result'][0]['id']) + str(jsonutils.loads(result['output'])['result'][0]['id']) ) + + @test.attr(type='openstack') + def test_multi_vim_support_target_headers_and_service_catalog(self): + client_1 = self.alt_client + client_2 = self.client + + # List stacks with client1, but with the target headers of client2, + # and additionally with an invalid X-Target-Service-Catalog. + extra_headers = _extract_target_headers_from_client(client_2) + service_dict = dict(client_2.auth_provider.cache[1]) + + for endpoint in service_dict['serviceCatalog']: + if endpoint['name'] == 'heat': + endpoint['endpoints'][0]['publicURL'] = "invalid" + + service_catalog = { + "X-Target-Service-Catalog": base64.b64encode( + jsonutils.dumps(service_dict) + ) + } + extra_headers.update(service_catalog) + result = _execute_action( + client_1, + _get_list_stack_request(), + extra_headers=extra_headers + ) + + # Assert that the invalid catalog was used. + self.assertIn("Invalid URL", result['output']) + + +def _extract_target_headers_from_client(client): + u = urlparse(client.auth_provider.auth_url) + v3_auth_url = '{}://{}/identity/v3/'.format(u.scheme, u.netloc) + return { + 'X-Target-Auth-Token': client.token, + 'X-Target-Auth-Uri': v3_auth_url, + 'X-Target-Project-Id': client.tenant_id, + 'X-Target-User-Id': client.user_id, + } + + +def _execute_action(client, request, extra_headers={}): + _, result = client.create_action_execution( + request, + extra_headers=extra_headers + ) + + return result + + +def _get_create_stack_request(): + stack_name = 'multi_vim_test_stack_{}'.format(str(uuid.uuid4())[:8]) + + return { + 'name': 'heat.stacks_create', + 'input': { + 'stack_name': stack_name, + "template": {"heat_template_version": "2013-05-23"} + } + } + + +def _get_list_stack_request(): + return { + 'name': 'heat.stacks_list', + }