From 8df5c7544698496775e8b72904e1331c3cb94456 Mon Sep 17 00:00:00 2001 From: Mike Fedosin Date: Tue, 10 Nov 2015 15:53:25 +0300 Subject: [PATCH] Implement trust support for api v2 Implement trust support for images api v2 when uploading images with registry. The algorithm is the following: 1. If 'registry' is set as data_api backend create a trust 2. Upload an image 3. Try to update the image status to 'active' in registry 4. If trust has been created succcessfully and 401 occured during updating the image status then renew the token and try to update the image record in registry again Co-Authored-By: Mike Fedosin Co-Authored-By: Kairat Kushaev Implements bp trust-authentication Change-Id: Ia3b82782b14f5dfc93457620633c1039c38fc366 --- glance/api/v2/image_data.py | 49 +++++++- glance/common/trust_auth.py | 117 ++++++++++++++++++ .../tests/unit/v2/test_image_data_resource.py | 53 ++++++++ ...ust-support-registry-cfd17a6a9ab21d70.yaml | 7 ++ requirements.txt | 1 + 5 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 glance/common/trust_auth.py create mode 100644 releasenotes/notes/trust-support-registry-cfd17a6a9ab21d70.yaml diff --git a/glance/api/v2/image_data.py b/glance/api/v2/image_data.py index 03ab8aa475..65ec582bbe 100644 --- a/glance/api/v2/image_data.py +++ b/glance/api/v2/image_data.py @@ -13,6 +13,7 @@ # License for the specific language governing permissions and limitations # under the License. import glance_store +from oslo_config import cfg from oslo_log import log as logging from oslo_utils import encodeutils from oslo_utils import excutils @@ -20,16 +21,19 @@ import webob.exc import glance.api.policy from glance.common import exception +from glance.common import trust_auth from glance.common import utils from glance.common import wsgi import glance.db import glance.gateway -from glance.i18n import _, _LE +from glance.i18n import _, _LE, _LI import glance.notifier LOG = logging.getLogger(__name__) +CONF = cfg.CONF + class ImageDataController(object): def __init__(self, db_api=None, store_api=None, @@ -81,13 +85,54 @@ class ImageDataController(object): def upload(self, req, image_id, data, size): image_repo = self.gateway.get_repo(req.context) image = None + refresher = None + cxt = req.context try: image = image_repo.get(image_id) image.status = 'saving' try: + if CONF.data_api == 'glance.db.registry.api': + # create a trust if backend is registry + try: + # request user pluging for current token + user_plugin = req.environ.get('keystone.token_auth') + roles = [] + # use roles from request environment because they + # are not transformed to lower-case unlike cxt.roles + for role_info in req.environ.get( + 'keystone.token_info')['token']['roles']: + roles.append(role_info['name']) + refresher = trust_auth.TokenRefresher(user_plugin, + cxt.tenant, + roles) + except Exception as e: + LOG.info(_LI("Unable to create trust: %s " + "Use the existing user token."), + encodeutils.exception_to_unicode(e)) + image_repo.save(image) image.set_data(data, size) - image_repo.save(image, from_state='saving') + + try: + image_repo.save(image, from_state='saving') + except exception.NotAuthenticated as e: + if refresher is not None: + # request a new token to update an image in database + cxt.auth_token = refresher.refresh_token() + image_repo = self.gateway.get_repo(req.context) + image_repo.save(image, from_state='saving') + else: + raise e + + try: + # release resources required for re-auth + if refresher is not None: + refresher.release_resources() + except Exception as e: + LOG.info(_LI("Unable to delete trust %(trust)s: %(msg)s"), + {"trust": refresher.trust_id, + "msg": encodeutils.exception_to_unicode(e)}) + except (glance_store.NotFound, exception.ImageNotFound, exception.Conflict): diff --git a/glance/common/trust_auth.py b/glance/common/trust_auth.py new file mode 100644 index 0000000000..67963a2589 --- /dev/null +++ b/glance/common/trust_auth.py @@ -0,0 +1,117 @@ +# Copyright (c) 2015 Mirantis, Inc. +# +# 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 keystoneauth1.identity import v3 +from keystoneauth1.loading import conf +from keystoneauth1.loading import session +from keystoneclient import exceptions as ks_exceptions +from keystoneclient.v3 import client as ks_client +from oslo_config import cfg +from oslo_log import log as logging + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + + +class TokenRefresher(object): + """Class that responsible for token refreshing with trusts""" + + def __init__(self, user_plugin, user_project, user_roles): + """Prepare all parameters and clients required to refresh token""" + + # step 1: Prepare parameters required to connect to keystone + self.auth_url = CONF.keystone_authtoken.auth_uri + if not self.auth_url.endswith('/v3'): + self.auth_url += '/v3' + + self.ssl_settings = { + 'cacert': CONF.keystone_authtoken.cafile, + 'insecure': CONF.keystone_authtoken.insecure, + 'cert': CONF.keystone_authtoken.certfile, + 'key': CONF.keystone_authtoken.keyfile, + } + + # step 2: create trust to ensure that we can always update token + + # trustor = user who made the request + trustor_client = self._load_client(user_plugin, self.ssl_settings) + trustor_id = trustor_client.session.get_user_id() + + # get trustee user client that impersonates main user + trustee_user_auth = conf.load_from_conf_options(CONF, + 'keystone_authtoken') + # save service user client because we need new service token + # to refresh trust-scoped client later + self.trustee_user_client = self._load_client(trustee_user_auth, + self.ssl_settings) + trustee_id = self.trustee_user_client.session.get_user_id() + + self.trust_id = trustor_client.trusts.create(trustor_user=trustor_id, + trustee_user=trustee_id, + impersonation=True, + role_names=user_roles, + project=user_project).id + LOG.debug("Trust %s has been created.", self.trust_id) + + # step 3: postpone trust-scoped client initialization + # until we need to refresh the token + self.trustee_client = None + + def refresh_token(self): + """Receive new token if user need to update old token + + :return: new token that can be used for authentication + """ + LOG.debug("Requesting the new token with trust %s", self.trust_id) + if self.trustee_client is None: + self.trustee_client = self._refresh_trustee_client() + try: + return self.trustee_client.session.get_token() + except ks_exceptions.Unauthorized: + # in case of Unauthorized exceptions try to refresh client because + # service user token may expired + self.trustee_client = self._refresh_trustee_client() + return self.trustee_client.session.get_token() + + def release_resources(self): + """Release keystone resources required for refreshing""" + + try: + if self.trustee_client is None: + self._refresh_trustee_client().trusts.delete(self.trust_id) + else: + self.trustee_client.trusts.delete(self.trust_id) + except ks_exceptions.Unauthorized: + # service user token may expire when we are trying to delete token + # so need to update client to ensure that this is not the reason + # of failure + self.trustee_client = self._refresh_trustee_client() + self.trustee_client.trusts.delete(self.trust_id) + + def _refresh_trustee_client(self): + trustee_token = self.trustee_user_client.session.get_token() + trustee_auth = v3.Token( + trust_id=self.trust_id, + token=trustee_token, + auth_url=self.auth_url + ) + return self._load_client(trustee_auth, self.ssl_settings) + + @staticmethod + def _load_client(plugin, ssl_settings): + # load client from auth settings and user plugin + sess = session.Session().load_from_options( + auth=plugin, **ssl_settings) + return ks_client.Client(session=sess) diff --git a/glance/tests/unit/v2/test_image_data_resource.py b/glance/tests/unit/v2/test_image_data_resource.py index f0e787a197..9d6ed1289f 100644 --- a/glance/tests/unit/v2/test_image_data_resource.py +++ b/glance/tests/unit/v2/test_image_data_resource.py @@ -365,6 +365,59 @@ class TestImagesController(base.StoreClearingUnitTest): self.controller.upload, request, unit_test_utils.UUID2, 'YY', 2) + @mock.patch("glance.common.trust_auth.TokenRefresher") + def test_upload_with_trusts(self, mock_refresher): + """Test that uploading with registry correctly uses trusts""" + # initialize trust environment + self.config(data_api='glance.db.registry.api') + refresher = mock.MagicMock() + mock_refresher.return_value = refresher + refresher.refresh_token.return_value = "fake_token" + # request an image upload + request = unit_test_utils.get_fake_request() + request.environ['keystone.token_auth'] = mock.MagicMock() + request.environ['keystone.token_info'] = { + 'token': { + 'roles': [{'name': 'FakeRole', 'id': 'FakeID'}] + } + } + image = FakeImage('abcd') + self.image_repo.result = image + mock_fake_save = mock.Mock() + mock_fake_save.side_effect = [None, exception.NotAuthenticated, None] + temp_save = FakeImageRepo.save + # mocking save to raise NotAuthenticated on the second call + FakeImageRepo.save = mock_fake_save + self.controller.upload(request, unit_test_utils.UUID2, 'YYYY', 4) + # check image data + self.assertEqual('YYYY', image.data) + self.assertEqual(4, image.size) + FakeImageRepo.save = temp_save + # check that token has been correctly acquired and deleted + mock_refresher.assert_called_once_with( + request.environ['keystone.token_auth'], + request.context.tenant, ['FakeRole']) + refresher.refresh_token.assert_called_once_with() + refresher.release_resources.assert_called_once_with() + self.assertEqual("fake_token", request.context.auth_token) + + @mock.patch("glance.common.trust_auth.TokenRefresher") + def test_upload_with_trusts_fails(self, mock_refresher): + """Test upload with registry if trust was not successfully created""" + # initialize trust environment + self.config(data_api='glance.db.registry.api') + mock_refresher().side_effect = Exception() + # request an image upload + request = unit_test_utils.get_fake_request() + image = FakeImage('abcd') + self.image_repo.result = image + self.controller.upload(request, unit_test_utils.UUID2, 'YYYY', 4) + # check image data + self.assertEqual('YYYY', image.data) + self.assertEqual(4, image.size) + # check that the token has not been updated + self.assertEqual(0, mock_refresher().refresh_token.call_count) + def _test_upload_download_prepare_notification(self): request = unit_test_utils.get_fake_request() self.controller.upload(request, unit_test_utils.UUID2, 'YYYY', 4) diff --git a/releasenotes/notes/trust-support-registry-cfd17a6a9ab21d70.yaml b/releasenotes/notes/trust-support-registry-cfd17a6a9ab21d70.yaml new file mode 100644 index 0000000000..e2279099be --- /dev/null +++ b/releasenotes/notes/trust-support-registry-cfd17a6a9ab21d70.yaml @@ -0,0 +1,7 @@ +--- +features: + - Implemented re-authentication with trusts when updating image status in + registry after image upload. When long-running image upload takes some a lot + of time (more than token expiration time) glance uses trusts to receive new + token and update image status in registry. It allows users to upload big + size images without increasing token expiration time. diff --git a/requirements.txt b/requirements.txt index b828b89ed8..b6f17c9413 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ oslo.utils>=3.2.0 # Apache-2.0 stevedore>=1.5.0 # Apache-2.0 futurist>=0.6.0 # Apache-2.0 taskflow>=1.25.0 +keystoneauth1>=2.1.0 keystonemiddleware>=4.0.0 WSME>=0.8