Clean up keystone authentication
Organize keystone authentication code for testability and better mainatainability. Change-Id: Iab065a2047f3ef1ff2282223f4f06b1e83f07657
This commit is contained in:
parent
4d8d9e98be
commit
69e8925b20
@ -53,7 +53,7 @@ class Client(object):
|
||||
|
||||
# If auth url was provided then we perform an authentication, otherwise
|
||||
# just ignore this step
|
||||
if req.get('auth_url'):
|
||||
if req.get('auth_url') or req.get('target_auth_url'):
|
||||
auth_handler = auth.get_auth_handler(auth_type)
|
||||
auth_response = auth_handler.authenticate(req, session=session)
|
||||
else:
|
||||
|
@ -12,6 +12,9 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
import logging
|
||||
|
||||
import keystoneauth1.access.service_catalog as sc
|
||||
import keystoneauth1.identity.generic as auth_plugin
|
||||
from keystoneauth1 import session as ks_session
|
||||
import mistralclient.api.httpclient as api
|
||||
@ -19,87 +22,145 @@ from mistralclient import auth as mistral_auth
|
||||
from oslo_serialization import jsonutils
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KeystoneAuthHandler(mistral_auth.AuthHandler):
|
||||
|
||||
def authenticate(self, req, session=None):
|
||||
"""Perform authentication via Keystone.
|
||||
|
||||
:param req: Request dict containing list of parameters required
|
||||
:param req: Request dict containing the parameters required
|
||||
for Keystone authentication.
|
||||
|
||||
"""
|
||||
if not isinstance(req, dict):
|
||||
raise TypeError('The input "req" is not typeof dict.')
|
||||
reqs = self._separate_target_reqs(req)
|
||||
try:
|
||||
result = self._authenticate(reqs, session)
|
||||
except Exception as e:
|
||||
if "Cannot use v2 authentication with domain scope" in str(e):
|
||||
LOG.warning("Client tried to use v2 authentication with "
|
||||
"domain scope. Domain parameters are assumed "
|
||||
"to be erroneously set. Retrying "
|
||||
"authentication without them. "
|
||||
"Request parameters: %s" % str(reqs))
|
||||
domainless_reqs = [reqs[0],
|
||||
self._remove_domain(reqs[1])]
|
||||
result = self._authenticate(domainless_reqs, session)
|
||||
else:
|
||||
raise
|
||||
return result
|
||||
|
||||
session = session
|
||||
mistral_url = req.get('mistral_url')
|
||||
endpoint_type = req.get('endpoint_type', 'publicURL')
|
||||
service_type = req.get('service_type', 'workflowv2')
|
||||
@staticmethod
|
||||
def _separate_target_reqs(req):
|
||||
"""Separates parameters into target and non-target ones.
|
||||
|
||||
auth_url = req.get('auth_url')
|
||||
username = req.get('username')
|
||||
user_id = req.get('user_id')
|
||||
api_key = req.get('api_key')
|
||||
auth_token = req.get('auth_token')
|
||||
project_name = req.get('project_name')
|
||||
project_id = req.get('project_id')
|
||||
region_name = req.get('region_name')
|
||||
user_domain_name = req.get('user_domain_name')
|
||||
user_domain_id = req.get('user_domain_id')
|
||||
project_domain_name = req.get('project_domain_name')
|
||||
project_domain_id = req.get('project_domain_id')
|
||||
target_auth_url = req.get('target_auth_url')
|
||||
target_username = req.get('target_username')
|
||||
target_user_id = req.get('target_user_id')
|
||||
target_api_key = req.get('target_api_key')
|
||||
target_auth_token = req.get('target_auth_token')
|
||||
target_project_name = req.get('target_project_name')
|
||||
target_project_id = req.get('target_project_id')
|
||||
target_user_domain_name = req.get('target_user_domain_name')
|
||||
target_user_domain_id = req.get('target_user_domain_id')
|
||||
target_project_domain_name = req.get('target_project_domain_name')
|
||||
target_project_domain_id = req.get('target_project_domain_id')
|
||||
target_* parameters are rekeyed by removing the prefix.
|
||||
|
||||
:param req: Request dict containing the parameters for Keystone
|
||||
authentication.
|
||||
:return: list of [non-target, target] request parameters
|
||||
"""
|
||||
r = {}
|
||||
target_r = {}
|
||||
target_prefix = "target_"
|
||||
for key in req:
|
||||
if key.startswith(target_prefix):
|
||||
target_r[key[len(target_prefix):]] = req[key]
|
||||
else:
|
||||
r[key] = req[key]
|
||||
|
||||
return [r, target_r]
|
||||
|
||||
@staticmethod
|
||||
def _remove_domain(req):
|
||||
"""Remove all domain parameters from req.
|
||||
|
||||
Keystoneauth with V2 does not accept domain parameters. This
|
||||
is an incompatible change from Keystoneclient but it would
|
||||
unnecessarily break clients of Mistral. It is safe to remove
|
||||
domain parameters if V2 auth is targeted.
|
||||
|
||||
:param req: Request dict containing list of parameters required
|
||||
:return: Request dict without domains
|
||||
"""
|
||||
r = {}
|
||||
for key in req:
|
||||
if "domain" not in key:
|
||||
r[key] = req[key]
|
||||
return r
|
||||
|
||||
@staticmethod
|
||||
def _get_auth(api_key=None, auth_token=None, auth_url=None,
|
||||
project_domain_id=None, project_domain_name=None,
|
||||
project_id=None, project_name=None, user_domain_id=None,
|
||||
user_domain_name=None, user_id=None, username=None,
|
||||
**kwargs):
|
||||
|
||||
if project_name and project_id:
|
||||
raise RuntimeError(
|
||||
'Only project name or project id should be set'
|
||||
'Only one of project_name or project_id should be set'
|
||||
)
|
||||
|
||||
if username and user_id:
|
||||
raise RuntimeError(
|
||||
'Only user name or user id should be set'
|
||||
'Only one of username or user_id should be set'
|
||||
)
|
||||
|
||||
auth_response = {}
|
||||
auth = {}
|
||||
|
||||
if not session:
|
||||
if auth_token:
|
||||
auth = auth_plugin.Token(
|
||||
auth_url=auth_url,
|
||||
token=auth_token,
|
||||
project_domain_id=project_domain_id,
|
||||
project_domain_name=project_domain_name,
|
||||
project_id=project_id,
|
||||
project_name=project_name,
|
||||
project_domain_name=project_domain_name,
|
||||
project_domain_id=project_domain_id)
|
||||
|
||||
token=auth_token
|
||||
)
|
||||
elif api_key and (username or user_id):
|
||||
auth = auth_plugin.Password(
|
||||
auth_url=auth_url,
|
||||
username=username,
|
||||
user_id=user_id,
|
||||
password=api_key,
|
||||
user_domain_name=user_domain_name,
|
||||
user_domain_id=user_domain_id,
|
||||
project_domain_id=project_domain_id,
|
||||
project_domain_name=project_domain_name,
|
||||
project_id=project_id,
|
||||
project_name=project_name,
|
||||
project_domain_name=project_domain_name,
|
||||
project_domain_id=project_domain_id)
|
||||
else:
|
||||
raise RuntimeError("You must either provide a valid token or "
|
||||
"a password (api_key) and a user.")
|
||||
user_domain_id=user_domain_id,
|
||||
user_domain_name=user_domain_name,
|
||||
user_id=user_id,
|
||||
username=username
|
||||
)
|
||||
|
||||
return auth
|
||||
|
||||
def _authenticate(self, reqs, session=None):
|
||||
"""Performs authentication via Keystone.
|
||||
|
||||
:param reqs: Request dict containing list of parameters required
|
||||
for Keystone authentication.
|
||||
:return: Auth response dict
|
||||
"""
|
||||
if not isinstance(reqs[0], dict):
|
||||
raise TypeError('The input "req" is not typeof dict.')
|
||||
if not isinstance(reqs[1], dict):
|
||||
raise TypeError('The input "req" is not typeof dict.')
|
||||
|
||||
auth_response = {}
|
||||
req = reqs[0]
|
||||
cacert = req.get('cacert')
|
||||
endpoint_type = req.get('endpoint_type', 'publicURL')
|
||||
insecure = req.get('insecure')
|
||||
mistral_url = req.get('mistral_url')
|
||||
region_name = req.get('region_name')
|
||||
service_type = req.get('service_type', 'workflowv2')
|
||||
|
||||
verify = self._verification_needed(cacert, insecure)
|
||||
|
||||
if not session:
|
||||
auth = self._get_auth(**req)
|
||||
|
||||
if auth:
|
||||
session = ks_session.Session(auth=auth)
|
||||
session = ks_session.Session(auth=auth, verify=verify)
|
||||
|
||||
if session:
|
||||
if not mistral_url:
|
||||
@ -115,47 +176,75 @@ class KeystoneAuthHandler(mistral_auth.AuthHandler):
|
||||
auth_response['mistral_url'] = mistral_url
|
||||
auth_response['session'] = session
|
||||
|
||||
if target_auth_url:
|
||||
if target_auth_token:
|
||||
target_auth = auth_plugin.Token(
|
||||
auth_url=target_auth_url,
|
||||
token=target_auth_token,
|
||||
project_id=target_project_id,
|
||||
project_name=target_project_name,
|
||||
project_domain_name=target_project_domain_name,
|
||||
project_domain_id=target_project_domain_id)
|
||||
target_req = reqs[1]
|
||||
|
||||
elif target_api_key and (target_username or target_user_id):
|
||||
target_auth = auth_plugin.Password(
|
||||
auth_url=target_auth_url,
|
||||
username=target_username,
|
||||
user_id=target_user_id,
|
||||
password=target_api_key,
|
||||
user_domain_name=target_user_domain_name,
|
||||
user_domain_id=target_user_domain_id,
|
||||
project_id=target_project_id,
|
||||
project_name=target_project_name,
|
||||
project_domain_name=target_project_domain_name,
|
||||
project_domain_id=target_project_domain_id)
|
||||
else:
|
||||
raise RuntimeError("You must either provide a valid token or "
|
||||
"a password (target_api_key) and a user.")
|
||||
if "auth_url" in target_req:
|
||||
target_auth = self._get_auth(**target_req)
|
||||
|
||||
if target_auth:
|
||||
|
||||
# target cacert and insecure
|
||||
cacert = target_req.get('cacert')
|
||||
insecure = target_req.get('insecure')
|
||||
|
||||
verify = self._verification_needed(cacert, insecure)
|
||||
|
||||
target_session = ks_session.Session(
|
||||
auth=target_auth,
|
||||
verify=verify
|
||||
)
|
||||
|
||||
target_session = ks_session.Session(auth=target_auth)
|
||||
target_auth_headers = target_session.get_auth_headers() or {}
|
||||
|
||||
# NOTE: (sharatss) The target_auth_token is required here so that
|
||||
# it can be passed as a separate header later.
|
||||
target_auth_token = target_auth_headers.get('X-Auth-Token')
|
||||
|
||||
auth_response.update({
|
||||
api.TARGET_AUTH_TOKEN: target_auth_token,
|
||||
api.TARGET_PROJECT_ID: target_session.get_project_id(),
|
||||
api.TARGET_USER_ID: target_session.get_user_id(),
|
||||
api.TARGET_AUTH_URI: target_auth_url,
|
||||
api.TARGET_SERVICE_CATALOG: jsonutils.dumps(
|
||||
target_auth.get_access(
|
||||
target_session).service_catalog.catalog)
|
||||
api.TARGET_AUTH_URI: target_auth._plugin.auth_url,
|
||||
})
|
||||
|
||||
access = target_auth.get_access(target_session)
|
||||
service_catalog = access.service_catalog
|
||||
|
||||
if self._is_service_catalog_v2(service_catalog):
|
||||
access_data = access._data["access"]
|
||||
if not len(access_data['serviceCatalog']):
|
||||
LOG.warning(
|
||||
"Service Catalog empty, some authentication"
|
||||
"credentials may be missing. This can cause"
|
||||
"malfunction in the Mistral action executions.")
|
||||
sc_json = jsonutils.dumps(access_data)
|
||||
auth_response[api.TARGET_SERVICE_CATALOG] = sc_json
|
||||
|
||||
if not auth_response:
|
||||
LOG.debug("No valid token or password + user provided. "
|
||||
"Continuing without authentication")
|
||||
return {}
|
||||
|
||||
return auth_response
|
||||
|
||||
@staticmethod
|
||||
def _verification_needed(cacert, insecure):
|
||||
"""Return the verify parameter.
|
||||
|
||||
The value of verify can be either True/False or a cacert.
|
||||
|
||||
:param cacert None or path to CA cert file
|
||||
:param insecure truthy value to switch on SSL verification
|
||||
"""
|
||||
if insecure is False or insecure is None:
|
||||
verify = cacert or True
|
||||
else:
|
||||
verify = False
|
||||
return verify
|
||||
|
||||
@staticmethod
|
||||
def _is_service_catalog_v2(catalog):
|
||||
"""Check if the service catalog is of type ServiceCatalogV2
|
||||
|
||||
:param catalog: the service catalog
|
||||
:return: True if V2, False otherwise
|
||||
"""
|
||||
return type(catalog) is sc.ServiceCatalogV2
|
||||
|
@ -109,6 +109,8 @@ class BaseClientTests(base.BaseTestCase):
|
||||
mistral_url_for_http = http_client_mock.call_args[0][0]
|
||||
self.assertEqual(MISTRAL_HTTP_URL, mistral_url_for_http)
|
||||
|
||||
@mock.patch('mistralclient.auth.keystone.KeystoneAuthHandler'
|
||||
'._is_service_catalog_v2', return_value=True)
|
||||
@mock.patch('keystoneauth1.identity.generic.Password')
|
||||
@mock.patch('keystoneauth1.session.Session')
|
||||
@mock.patch('mistralclient.api.httpclient.HTTPClient')
|
||||
@ -116,14 +118,18 @@ class BaseClientTests(base.BaseTestCase):
|
||||
self,
|
||||
http_client_mock,
|
||||
session_mock,
|
||||
password_mock
|
||||
password_mock,
|
||||
catalog_type_mock
|
||||
):
|
||||
|
||||
session = mock.MagicMock()
|
||||
target_session = mock.MagicMock()
|
||||
session_mock.side_effect = [session, target_session]
|
||||
auth = mock.MagicMock()
|
||||
password_mock.side_effect = [auth, auth]
|
||||
target_auth = mock.MagicMock()
|
||||
target_auth._plugin.auth_url = AUTH_HTTP_URL_v3
|
||||
|
||||
password_mock.side_effect = [auth, target_auth]
|
||||
|
||||
get_endpoint = mock.Mock(return_value='http://mistral_host:8989/v2')
|
||||
session.get_endpoint = get_endpoint
|
||||
|
70
mistralclient/tests/unit/v2/test_keystone.py
Normal file
70
mistralclient/tests/unit/v2/test_keystone.py
Normal file
@ -0,0 +1,70 @@
|
||||
# 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 mistralclient.auth.keystone
|
||||
from mistralclient.tests.unit.v2 import base
|
||||
|
||||
|
||||
class TestKeystone(base.BaseClientV2Test):
|
||||
def setUp(self):
|
||||
super(TestKeystone, self).setUp()
|
||||
self.keystone = mistralclient.auth.keystone.KeystoneAuthHandler()
|
||||
|
||||
def test_get_auth_token(self):
|
||||
auth = self.keystone._get_auth(
|
||||
auth_token="token",
|
||||
auth_url="url",
|
||||
project_id="project_id",
|
||||
)
|
||||
|
||||
self.assertEqual("url", auth.auth_url)
|
||||
elements = auth.get_cache_id_elements()
|
||||
self.assertIsNotNone(elements["token"])
|
||||
self.assertIsNotNone(elements["project_id"])
|
||||
|
||||
def test_remove_domain(self):
|
||||
params = {
|
||||
"param1": "p",
|
||||
"target_param2": "p2",
|
||||
"user_domain_param3": "p3",
|
||||
"target_project_domain_param4": "p4"
|
||||
}
|
||||
dedomained = self.keystone._remove_domain(params)
|
||||
self.assertIn("param1", dedomained)
|
||||
self.assertIn("target_param2", dedomained)
|
||||
self.assertNotIn("user_domain_param3", dedomained)
|
||||
self.assertNotIn("target_project_domain_param4", dedomained)
|
||||
|
||||
def test_separate_target_reqs(self):
|
||||
params = {
|
||||
"a": 1,
|
||||
"target_b": 2,
|
||||
"c": 3,
|
||||
"target_d": 4,
|
||||
"target_target": 5,
|
||||
"param_target": 6
|
||||
}
|
||||
nontarget, target = self.keystone._separate_target_reqs(params)
|
||||
self.assertIn("a", nontarget)
|
||||
self.assertIn("c", nontarget)
|
||||
self.assertIn("param_target", nontarget)
|
||||
self.assertIn("b", target)
|
||||
self.assertIn("d", target)
|
||||
self.assertIn("target", target)
|
||||
|
||||
def test_verify(self):
|
||||
self.assertTrue(self.keystone._verification_needed("", False))
|
||||
self.assertFalse(self.keystone._verification_needed("", True))
|
||||
self.assertFalse(self.keystone._verification_needed("cert", True))
|
||||
self.assertEqual(
|
||||
self.keystone._verification_needed("cert", False),
|
||||
"cert"
|
||||
)
|
Loading…
Reference in New Issue
Block a user