From dc8d98dc6e9eacbd2b4f76fb423a288537356ecb Mon Sep 17 00:00:00 2001 From: Anderson Mesquita Date: Mon, 17 Feb 2014 16:11:39 -0600 Subject: [PATCH] Add Keystone V2 plugin This adds the previous Keystone V2 client to be used as a plugin by clouds that have not upgraded to V3 yet. This replacement also raises NotSupported exceptions in methods that are V3 only. Co-Authored-By: Richard Lee Closes-Bug: #1274201 Change-Id: I97d3fe7e5ff52250c699c9b470d114e53888ef15 --- contrib/heat_keystoneclient_v2/README.md | 29 ++ .../heat_keystoneclient_v2/__init__.py | 0 .../heat_keystoneclient_v2/client.py | 231 ++++++++++++++++ .../heat_keystoneclient_v2/tests/__init__.py | 0 .../tests/test_client.py | 253 ++++++++++++++++++ 5 files changed, 513 insertions(+) create mode 100644 contrib/heat_keystoneclient_v2/README.md create mode 100644 contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/__init__.py create mode 100644 contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py create mode 100644 contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/__init__.py create mode 100644 contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/test_client.py diff --git a/contrib/heat_keystoneclient_v2/README.md b/contrib/heat_keystoneclient_v2/README.md new file mode 100644 index 0000000000..2708902a1f --- /dev/null +++ b/contrib/heat_keystoneclient_v2/README.md @@ -0,0 +1,29 @@ +# Heat Keystone V2 + +This plugin is a Keystone V2 compatible client. It can be used to +replace the default client for clouds running older versions of +Keystone. + +Some forward compatibility decisions had to be made: + +* Stack domain users are created as users on the stack owner's tenant + rather than the stack's domain +* Trusts are not supported + + +# Installation + +1. In `heat.conf`, add the path to the `heat_keystoneclient_v2` root + directory to `plugin_dirs`. + e.g.: `plugin_dirs=path/to/heat/contrib/heat_keystoneclient_v2` + +2. Set the `keystone_backend` option to + `heat.engine.plugins.heat_keystoneclient_v2.client.KeystoneClientV2` + + +# How it works + +By setting the `keystone_backend` option, the KeystoneBackend class in +`heat/common/heat_keystoneclient.py` will instantiate the plugin +KeystoneClientV2 class and use that instead of the default client in +`heat/common/heat_keystoneclient.py`. diff --git a/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/__init__.py b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py new file mode 100644 index 0000000000..8f00183324 --- /dev/null +++ b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/client.py @@ -0,0 +1,231 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, 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. + +from heat.common import exception + +from keystoneclient.v2_0 import client as kc +from oslo.config import cfg + +from heat.openstack.common import importutils +from heat.openstack.common import log as logging +from heat.openstack.common.gettextutils import _ + +logger = logging.getLogger('heat.common.keystoneclient') +logger.info(_("Keystone V2 loaded")) + + +class KeystoneClientV2(object): + """ + Wrap keystone client so we can encapsulate logic used in resources + Note this is intended to be initialized from a resource on a per-session + basis, so the session context is passed in on initialization + Also note that a copy of this is created every resource as self.keystone() + via the code in engine/client.py, so there should not be any need to + directly instantiate instances of this class inside resources themselves + """ + def __init__(self, context): + # If a trust_id is specified in the context, we immediately + # authenticate so we can populate the context with a trust token + # otherwise, we delay client authentication until needed to avoid + # unnecessary calls to keystone. + # + # Note that when you obtain a token using a trust, it cannot be + # used to reauthenticate and get another token, so we have to + # get a new trust-token even if context.auth_token is set. + # + # - context.auth_url is expected to contain the v2.0 keystone endpoint + self.context = context + self._client_v2 = None + + if self.context.trust_id: + # Create a connection to the v2 API, with the trust_id, this + # populates self.context.auth_token with a trust-scoped token + self._client_v2 = self._v2_client_init() + + @property + def client_v2(self): + if not self._client_v2: + self._client_v2 = self._v2_client_init() + return self._client_v2 + + def _v2_client_init(self): + kwargs = { + 'auth_url': self.context.auth_url + } + auth_kwargs = {} + # Note try trust_id first, as we can't reuse auth_token in that case + if self.context.trust_id is not None: + # We got a trust_id, so we use the admin credentials + # to authenticate, then re-scope the token to the + # trust impersonating the trustor user. + # Note that this currently requires the trustor tenant_id + # to be passed to the authenticate(), unlike the v3 call + kwargs.update(self._service_admin_creds()) + auth_kwargs['trust_id'] = self.context.trust_id + auth_kwargs['tenant_id'] = self.context.tenant_id + elif self.context.auth_token is not None: + kwargs['tenant_name'] = self.context.tenant + kwargs['token'] = self.context.auth_token + elif self.context.password is not None: + kwargs['username'] = self.context.username + kwargs['password'] = self.context.password + kwargs['tenant_name'] = self.context.tenant + kwargs['tenant_id'] = self.context.tenant_id + else: + logger.error(_("Keystone v2 API connection failed, no password " + "or auth_token!")) + raise exception.AuthorizationFailure() + kwargs['cacert'] = self._get_client_option('ca_file') + kwargs['insecure'] = self._get_client_option('insecure') + kwargs['cert'] = self._get_client_option('cert_file') + kwargs['key'] = self._get_client_option('key_file') + client_v2 = kc.Client(**kwargs) + + client_v2.authenticate(**auth_kwargs) + # If we are authenticating with a trust auth_kwargs are set, so set + # the context auth_token with the re-scoped trust token + if auth_kwargs: + # Sanity check + if not client_v2.auth_ref.trust_scoped: + logger.error(_("v2 trust token re-scoping failed!")) + raise exception.AuthorizationFailure() + # All OK so update the context with the token + self.context.auth_token = client_v2.auth_ref.auth_token + self.context.auth_url = kwargs.get('auth_url') + # Ensure the v2 API we're using is not impacted by keystone + # bug #1239303, otherwise we can't trust the user_id + if self.context.trustor_user_id != client_v2.auth_ref.user_id: + logger.error("Trust impersonation failed, bug #1239303 " + "suspected, you may need a newer keystone") + raise exception.AuthorizationFailure() + + return client_v2 + + @staticmethod + def _service_admin_creds(): + # Import auth_token to have keystone_authtoken settings setup. + importutils.import_module('keystoneclient.middleware.auth_token') + + creds = { + 'username': cfg.CONF.keystone_authtoken.admin_user, + 'password': cfg.CONF.keystone_authtoken.admin_password, + 'auth_url': cfg.CONF.keystone_authtoken.auth_uri, + 'tenant_name': cfg.CONF.keystone_authtoken.admin_tenant_name, + } + + return creds + + def _get_client_option(self, option): + try: + cfg.CONF.import_opt(option, 'heat.common.config', + group='clients_keystone') + return getattr(cfg.CONF.clients_keystone, option) + except (cfg.NoSuchGroupError, cfg.NoSuchOptError): + cfg.CONF.import_opt(option, 'heat.common.config', group='clients') + return getattr(cfg.CONF.clients, option) + + def create_stack_user(self, username, password=''): + """ + Create a user defined as part of a stack, either via template + or created internally by a resource. This user will be added to + the heat_stack_user_role as defined in the config + Returns the keystone ID of the resulting user + """ + if(len(username) > 64): + logger.warning(_("Truncating the username %s to the last 64 " + "characters.") % username) + #get the last 64 characters of the username + username = username[-64:] + user = self.client_v2.users.create(username, + password, + '%s@openstack.org' % + username, + tenant_id=self.context.tenant_id, + enabled=True) + + # We add the new user to a special keystone role + # This role is designed to allow easier differentiation of the + # heat-generated "stack users" which will generally have credentials + # deployed on an instance (hence are implicitly untrusted) + roles = self.client_v2.roles.list() + stack_user_role = [r.id for r in roles + if r.name == cfg.CONF.heat_stack_user_role] + if len(stack_user_role) == 1: + role_id = stack_user_role[0] + logger.debug(_("Adding user %(user)s to role %(role)s") % { + 'user': user.id, 'role': role_id}) + self.client_v2.roles.add_user_role(user.id, role_id, + self.context.tenant_id) + else: + logger.error(_("Failed to add user %(user)s to role %(role)s, " + "check role exists!") % {'user': username, + 'role': cfg.CONF.heat_stack_user_role}) + + return user.id + + def delete_stack_user(self, user_id): + self.client_v2.users.delete(user_id) + + def delete_ec2_keypair(self, user_id, accesskey): + self.client_v2.ec2.delete(user_id, accesskey) + + def get_ec2_keypair(self, access, user_id=None): + uid = user_id or self.client_v2.auth_ref.user_id + return self.client_v2.ec2.get(uid, access) + + def create_ec2_keypair(self, user_id=None): + uid = user_id or self.client_v2.auth_ref.user_id + return self.client_v2.ec2.create(uid, self.context.tenant_id) + + def disable_stack_user(self, user_id): + self.client_v2.users.update_enabled(user_id, False) + + def enable_stack_user(self, user_id): + self.client_v2.users.update_enabled(user_id, True) + + def url_for(self, **kwargs): + return self.client_v2.service_catalog.url_for(**kwargs) + + @property + def auth_token(self): + return self.client_v2.auth_token + + # ##################### # + # V3 Compatible Methods # + # ##################### # + + def create_stack_domain_user(self, username, project_id, password=None): + return self.create_stack_user(username, password) + + def delete_stack_domain_user(self, user_id, project_id): + return self.delete_stack_user(user_id) + + def create_stack_domain_project(self, project_id): + '''Use the tenant ID as domain project.''' + return self.context.tenant_id + + def delete_stack_domain_project(self, project_id): + '''Pass through method since no project was created.''' + pass + + # ###################### # + # V3 Unsupported Methods # + # ###################### # + + def create_trust_context(self): + raise exception.NotSupported(feature='Keystone Trusts') + + def delete_trust(self, trust_id): + raise exception.NotSupported(feature='Keystone Trusts') diff --git a/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/__init__.py b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/test_client.py b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/test_client.py new file mode 100644 index 0000000000..4ea40b8cca --- /dev/null +++ b/contrib/heat_keystoneclient_v2/heat_keystoneclient_v2/tests/test_client.py @@ -0,0 +1,253 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, 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 mock +import mox +from oslo.config import cfg + +from .. import client as heat_keystoneclient # noqa +from heat.common import exception +from heat.openstack.common import importutils +from heat.tests.common import HeatTestCase +from heat.tests import utils + + +class KeystoneClientTest(HeatTestCase): + """Test cases for heat.common.heat_keystoneclient.""" + + def setUp(self): + super(KeystoneClientTest, self).setUp() + self.ctx = utils.dummy_context() + + # Import auth_token to have keystone_authtoken settings setup. + importutils.import_module('keystoneclient.middleware.auth_token') + + dummy_url = 'http://server.test:5000/v2.0' + cfg.CONF.set_override('auth_uri', dummy_url, + group='keystone_authtoken') + cfg.CONF.set_override('admin_user', 'heat', + group='keystone_authtoken') + cfg.CONF.set_override('admin_password', 'verybadpass', + group='keystone_authtoken') + cfg.CONF.set_override('admin_tenant_name', 'service', + group='keystone_authtoken') + self.addCleanup(self.m.VerifyAll) + + def _stubs_v2(self, method='token', auth_ok=True, trust_scoped=True, + user_id='trustor_user_id'): + self.mock_ks_client = self.m.CreateMock(heat_keystoneclient.kc.Client) + self.m.StubOutWithMock(heat_keystoneclient.kc, "Client") + if method == 'token': + heat_keystoneclient.kc.Client( + auth_url=mox.IgnoreArg(), + tenant_name='test_tenant', + token='abcd1234', + cacert=None, + cert=None, + insecure=False, + key=None).AndReturn(self.mock_ks_client) + self.mock_ks_client.authenticate().AndReturn(auth_ok) + elif method == 'password': + heat_keystoneclient.kc.Client( + auth_url=mox.IgnoreArg(), + tenant_name='test_tenant', + tenant_id='test_tenant_id', + username='test_username', + password='password', + cacert=None, + cert=None, + insecure=False, + key=None).AndReturn(self.mock_ks_client) + self.mock_ks_client.authenticate().AndReturn(auth_ok) + if method == 'trust': + heat_keystoneclient.kc.Client( + auth_url='http://server.test:5000/v2.0', + password='verybadpass', + tenant_name='service', + username='heat', + cacert=None, + cert=None, + insecure=False, + key=None).AndReturn(self.mock_ks_client) + self.mock_ks_client.authenticate(trust_id='atrust123', + tenant_id='test_tenant_id' + ).AndReturn(auth_ok) + self.mock_ks_client.auth_ref = self.m.CreateMockAnything() + self.mock_ks_client.auth_ref.trust_scoped = trust_scoped + self.mock_ks_client.auth_ref.auth_token = 'atrusttoken' + self.mock_ks_client.auth_ref.user_id = user_id + + def test_username_length(self): + """Test that user names >64 characters are properly truncated.""" + + self._stubs_v2() + + # a >64 character user name and the expected version + long_user_name = 'U' * 64 + 'S' + good_user_name = long_user_name[-64:] + # mock keystone client user functions + self.mock_ks_client.users = self.m.CreateMockAnything() + mock_user = self.m.CreateMockAnything() + # when keystone is called, the name should have been truncated + # to the last 64 characters of the long name + (self.mock_ks_client.users.create(good_user_name, 'password', + mox.IgnoreArg(), enabled=True, + tenant_id=mox.IgnoreArg()) + .AndReturn(mock_user)) + # mock out the call to roles; will send an error log message but does + # not raise an exception + self.mock_ks_client.roles = self.m.CreateMockAnything() + self.mock_ks_client.roles.list().AndReturn([]) + self.m.ReplayAll() + # call create_stack_user with a long user name. + # the cleanup VerifyAll should verify that though we passed + # long_user_name, keystone was actually called with a truncated + # user name + self.ctx.trust_id = None + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + heat_ks_client.create_stack_user(long_user_name, password='password') + + def test_init_v2_password(self): + """Test creating the client, user/password context.""" + + self._stubs_v2(method='password') + self.m.ReplayAll() + + self.ctx.auth_token = None + self.ctx.trust_id = None + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertIsNotNone(heat_ks_client.client_v2) + + def test_init_v2_bad_nocreds(self): + """Test creating the client without trusts, no credentials.""" + + self.ctx.auth_token = None + self.ctx.username = None + self.ctx.password = None + self.ctx.trust_id = None + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertRaises(exception.AuthorizationFailure, + heat_ks_client._v2_client_init) + + def test_trust_init(self): + """Test consuming a trust when initializing.""" + + self._stubs_v2(method='trust') + self.m.ReplayAll() + + self.ctx.username = None + self.ctx.password = None + self.ctx.auth_token = None + self.ctx.trust_id = 'atrust123' + self.ctx.trustor_user_id = 'trustor_user_id' + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + client_v2 = heat_ks_client.client_v2 + self.assertIsNotNone(client_v2) + + def test_trust_init_fail(self): + """Test consuming a trust when initializing, error scoping.""" + + self._stubs_v2(method='trust', trust_scoped=False) + self.m.ReplayAll() + + self.ctx.username = None + self.ctx.password = None + self.ctx.auth_token = None + self.ctx.trust_id = 'atrust123' + self.ctx.trustor_user_id = 'trustor_user_id' + self.assertRaises(exception.AuthorizationFailure, + heat_keystoneclient.KeystoneClientV2, self.ctx) + + def test_trust_init_fail_impersonation(self): + """Test consuming a trust when initializing, impersonation error.""" + + self._stubs_v2(method='trust', user_id='wrong_user_id') + self.m.ReplayAll() + + self.ctx.username = 'heat' + self.ctx.password = None + self.ctx.auth_token = None + self.ctx.trust_id = 'atrust123' + self.ctx.trustor_user_id = 'trustor_user_id' + self.assertRaises(exception.AuthorizationFailure, + heat_keystoneclient.KeystoneClientV2, self.ctx) + + def test_trust_init_pw(self): + """Test trust_id is takes precedence username/password specified.""" + + self._stubs_v2(method='trust') + self.m.ReplayAll() + + self.ctx.auth_token = None + self.ctx.trust_id = 'atrust123' + self.ctx.trustor_user_id = 'trustor_user_id' + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertIsNotNone(heat_ks_client._client_v2) + + def test_trust_init_token(self): + """Test trust_id takes precedence when token specified.""" + + self._stubs_v2(method='trust') + self.m.ReplayAll() + + self.ctx.username = None + self.ctx.password = None + self.ctx.trust_id = 'atrust123' + self.ctx.trustor_user_id = 'trustor_user_id' + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertIsNotNone(heat_ks_client._client_v2) + + # ##################### # + # V3 Compatible Methods # + # ##################### # + + def test_create_stack_domain_user_pass_through_to_create_stack_user(self): + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + mock_create_stack_user = mock.Mock() + heat_ks_client.create_stack_user = mock_create_stack_user + heat_ks_client.create_stack_domain_user('username', 'project_id', + 'password') + mock_create_stack_user.assert_called_once_with('username', 'password') + + def test_delete_stack_domain_user_pass_through_to_delete_stack_user(self): + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + mock_delete_stack_user = mock.Mock() + heat_ks_client.delete_stack_user = mock_delete_stack_user + heat_ks_client.delete_stack_domain_user('user_id', 'project_id') + mock_delete_stack_user.assert_called_once_with('user_id') + + def test_create_stack_domain_project(self): + tenant_id = self.ctx.tenant_id + ks = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertEqual(tenant_id, ks.create_stack_domain_project('fakeid')) + + def test_delete_stack_domain_project(self): + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertIsNone(heat_ks_client.delete_stack_domain_project('fakeid')) + + # ###################### # + # V3 Unsupported Methods # + # ###################### # + + def test_create_trust_context(self): + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertRaises(exception.NotSupported, + heat_ks_client.create_trust_context) + + def test_delete_trust(self): + heat_ks_client = heat_keystoneclient.KeystoneClientV2(self.ctx) + self.assertRaises(exception.NotSupported, + heat_ks_client.delete_trust, + 'fake_trust_id')