blob: 2dd9d00ce7ad2558ec6557da69b0013af99b671c [file] [log] [blame]
Matthew Treinish9e26ca82016-02-23 11:43:20 -05001# Copyright 2014 Hewlett-Packard Development Company, L.P.
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -06002# Copyright 2016 Rackspace Inc.
Matthew Treinish9e26ca82016-02-23 11:43:20 -05003# All Rights Reserved.
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may
6# not use this file except in compliance with the License. You may obtain
7# a copy of the License at
8#
9# http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
16
17import abc
18import copy
19import datetime
20import re
21
22from oslo_log import log as logging
23import six
24from six.moves.urllib import parse as urlparse
25
26from tempest.lib import exceptions
27from tempest.lib.services.identity.v2 import token_client as json_v2id
28from tempest.lib.services.identity.v3 import token_client as json_v3id
29
30ISO8601_FLOAT_SECONDS = '%Y-%m-%dT%H:%M:%S.%fZ'
31ISO8601_INT_SECONDS = '%Y-%m-%dT%H:%M:%SZ'
32LOG = logging.getLogger(__name__)
33
34
Brant Knudsonf2d1f572016-04-11 15:02:01 -050035def replace_version(url, new_version):
36 parts = urlparse.urlparse(url)
37 version_path = '/%s' % new_version
Brant Knudson77293802016-04-11 15:14:54 -050038 path, subs = re.subn(r'(^|/)+v\d+(?:\.\d+)?',
39 version_path,
40 parts.path,
41 count=1)
42 if not subs:
43 path = '%s%s' % (parts.path.rstrip('/'), version_path)
Brant Knudsonf2d1f572016-04-11 15:02:01 -050044 url = urlparse.urlunparse((parts.scheme,
45 parts.netloc,
Brant Knudson77293802016-04-11 15:14:54 -050046 path,
Brant Knudsonf2d1f572016-04-11 15:02:01 -050047 parts.params,
48 parts.query,
49 parts.fragment))
50 return url
51
52
53def apply_url_filters(url, filters):
54 if filters.get('api_version', None) is not None:
55 url = replace_version(url, filters['api_version'])
56 parts = urlparse.urlparse(url)
57 if filters.get('skip_path', None) is not None and parts.path != '':
58 url = urlparse.urlunparse((parts.scheme,
59 parts.netloc,
60 '/',
61 parts.params,
62 parts.query,
63 parts.fragment))
64
65 return url
66
67
Matthew Treinish9e26ca82016-02-23 11:43:20 -050068@six.add_metaclass(abc.ABCMeta)
69class AuthProvider(object):
70 """Provide authentication"""
71
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +010072 SCOPES = set(['project'])
73
74 def __init__(self, credentials, scope='project'):
Matthew Treinish9e26ca82016-02-23 11:43:20 -050075 """Auth provider __init__
76
77 :param credentials: credentials for authentication
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +010078 :param scope: the default scope to be used by the credential providers
79 when requesting a token. Valid values depend on the
80 AuthProvider class implementation, and are defined in
81 the set SCOPES. Default value is 'project'.
Matthew Treinish9e26ca82016-02-23 11:43:20 -050082 """
83 if self.check_credentials(credentials):
84 self.credentials = credentials
85 else:
86 if isinstance(credentials, Credentials):
87 password = credentials.get('password')
88 message = "Credentials are: " + str(credentials)
89 if password is None:
90 message += " Password is not defined."
91 else:
92 message += " Password is defined."
93 raise exceptions.InvalidCredentials(message)
94 else:
95 raise TypeError("credentials object is of type %s, which is"
96 " not a valid Credentials object type." %
97 credentials.__class__.__name__)
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +010098 self._scope = None
99 self.scope = scope
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500100 self.cache = None
101 self.alt_auth_data = None
102 self.alt_part = None
103
104 def __str__(self):
105 return "Creds :{creds}, cached auth data: {cache}".format(
106 creds=self.credentials, cache=self.cache)
107
108 @abc.abstractmethod
109 def _decorate_request(self, filters, method, url, headers=None, body=None,
110 auth_data=None):
111 """Decorate request with authentication data"""
112 return
113
114 @abc.abstractmethod
115 def _get_auth(self):
116 return
117
118 @abc.abstractmethod
119 def _fill_credentials(self, auth_data_body):
120 return
121
122 def fill_credentials(self):
123 """Fill credentials object with data from auth"""
124 auth_data = self.get_auth()
125 self._fill_credentials(auth_data[1])
126 return self.credentials
127
128 @classmethod
129 def check_credentials(cls, credentials):
130 """Verify credentials are valid."""
131 return isinstance(credentials, Credentials) and credentials.is_valid()
132
133 @property
134 def auth_data(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100135 """Auth data for set scope"""
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500136 return self.get_auth()
137
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100138 @property
139 def scope(self):
140 """Scope used in auth requests"""
141 return self._scope
142
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500143 @auth_data.deleter
144 def auth_data(self):
145 self.clear_auth()
146
147 def get_auth(self):
148 """Returns auth from cache if available, else auth first"""
149 if self.cache is None or self.is_expired(self.cache):
150 self.set_auth()
151 return self.cache
152
153 def set_auth(self):
154 """Forces setting auth.
155
156 Forces setting auth, ignores cache if it exists.
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100157 Refills credentials.
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500158 """
159 self.cache = self._get_auth()
160 self._fill_credentials(self.cache[1])
161
162 def clear_auth(self):
163 """Clear access cache
164
165 Can be called to clear the access cache so that next request
166 will fetch a new token and base_url.
167 """
168 self.cache = None
169 self.credentials.reset()
170
171 @abc.abstractmethod
172 def is_expired(self, auth_data):
173 return
174
175 def auth_request(self, method, url, headers=None, body=None, filters=None):
176 """Obtains auth data and decorates a request with that.
177
178 :param method: HTTP method of the request
179 :param url: relative URL of the request (path)
180 :param headers: HTTP headers of the request
181 :param body: HTTP body in case of POST / PUT
182 :param filters: select a base URL out of the catalog
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900183 :return: a Tuple (url, headers, body)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500184 """
185 orig_req = dict(url=url, headers=headers, body=body)
186
187 auth_url, auth_headers, auth_body = self._decorate_request(
188 filters, method, url, headers, body)
189 auth_req = dict(url=auth_url, headers=auth_headers, body=auth_body)
190
191 # Overwrite part if the request if it has been requested
192 if self.alt_part is not None:
193 if self.alt_auth_data is not None:
194 alt_url, alt_headers, alt_body = self._decorate_request(
195 filters, method, url, headers, body,
196 auth_data=self.alt_auth_data)
197 alt_auth_req = dict(url=alt_url, headers=alt_headers,
198 body=alt_body)
199 if auth_req[self.alt_part] == alt_auth_req[self.alt_part]:
200 raise exceptions.BadAltAuth(part=self.alt_part)
201 auth_req[self.alt_part] = alt_auth_req[self.alt_part]
202
203 else:
204 # If the requested part is not affected by auth, we are
205 # not altering auth as expected, raise an exception
206 if auth_req[self.alt_part] == orig_req[self.alt_part]:
207 raise exceptions.BadAltAuth(part=self.alt_part)
208 # If alt auth data is None, skip auth in the requested part
209 auth_req[self.alt_part] = orig_req[self.alt_part]
210
211 # Next auth request will be normal, unless otherwise requested
212 self.reset_alt_auth_data()
213
214 return auth_req['url'], auth_req['headers'], auth_req['body']
215
216 def reset_alt_auth_data(self):
217 """Configure auth provider to provide valid authentication data"""
218 self.alt_part = None
219 self.alt_auth_data = None
220
221 def set_alt_auth_data(self, request_part, auth_data):
222 """Alternate auth data on next request
223
224 Configure auth provider to provide alt authentication data
225 on a part of the *next* auth_request. If credentials are None,
226 set invalid data.
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900227
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500228 :param request_part: request part to contain invalid auth: url,
229 headers, body
230 :param auth_data: alternative auth_data from which to get the
231 invalid data to be injected
232 """
233 self.alt_part = request_part
234 self.alt_auth_data = auth_data
235
236 @abc.abstractmethod
237 def base_url(self, filters, auth_data=None):
238 """Extracts the base_url based on provided filters"""
239 return
240
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100241 @scope.setter
242 def scope(self, value):
243 """Set the scope to be used in token requests
244
245 :param scope: scope to be used. If the scope is different, clear caches
246 """
247 if value not in self.SCOPES:
248 raise exceptions.InvalidScope(
249 scope=value, auth_provider=self.__class__.__name__)
250 if value != self.scope:
251 self.clear_auth()
252 self._scope = value
253
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500254
255class KeystoneAuthProvider(AuthProvider):
256
257 EXPIRY_DATE_FORMATS = (ISO8601_FLOAT_SECONDS, ISO8601_INT_SECONDS)
258
259 token_expiry_threshold = datetime.timedelta(seconds=60)
260
261 def __init__(self, credentials, auth_url,
262 disable_ssl_certificate_validation=None,
zhufl071e94c2016-07-12 10:26:34 +0800263 ca_certs=None, trace_requests=None, scope='project',
Matthew Treinish74514402016-09-01 11:44:57 -0400264 http_timeout=None, proxy_url=None):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100265 super(KeystoneAuthProvider, self).__init__(credentials, scope)
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100266 self.dscv = disable_ssl_certificate_validation
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500267 self.ca_certs = ca_certs
268 self.trace_requests = trace_requests
zhufl071e94c2016-07-12 10:26:34 +0800269 self.http_timeout = http_timeout
Matthew Treinish74514402016-09-01 11:44:57 -0400270 self.proxy_url = proxy_url
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100271 self.auth_url = auth_url
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500272 self.auth_client = self._auth_client(auth_url)
273
274 def _decorate_request(self, filters, method, url, headers=None, body=None,
275 auth_data=None):
276 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100277 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500278 token, _ = auth_data
279 base_url = self.base_url(filters=filters, auth_data=auth_data)
280 # build authenticated request
281 # returns new request, it does not touch the original values
282 _headers = copy.deepcopy(headers) if headers is not None else {}
283 _headers['X-Auth-Token'] = str(token)
284 if url is None or url == "":
285 _url = base_url
286 else:
287 # Join base URL and url, and remove multiple contiguous slashes
288 _url = "/".join([base_url, url])
289 parts = [x for x in urlparse.urlparse(_url)]
290 parts[2] = re.sub("/{2,}", "/", parts[2])
291 _url = urlparse.urlunparse(parts)
292 # no change to method or body
293 return str(_url), _headers, body
294
295 @abc.abstractmethod
296 def _auth_client(self):
297 return
298
299 @abc.abstractmethod
300 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100301 """Auth parameters to be passed to the token request
302
303 By default all fields available in Credentials are passed to the
304 token request. Scope may affect this.
305 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500306 return
307
308 def _get_auth(self):
309 # Bypasses the cache
310 auth_func = getattr(self.auth_client, 'get_token')
311 auth_params = self._auth_params()
312
313 # returns token, auth_data
314 token, auth_data = auth_func(**auth_params)
315 return token, auth_data
316
317 def _parse_expiry_time(self, expiry_string):
318 expiry = None
319 for date_format in self.EXPIRY_DATE_FORMATS:
320 try:
321 expiry = datetime.datetime.strptime(
322 expiry_string, date_format)
323 except ValueError:
324 pass
325 if expiry is None:
326 raise ValueError(
327 "time data '{data}' does not match any of the"
328 "expected formats: {formats}".format(
329 data=expiry_string, formats=self.EXPIRY_DATE_FORMATS))
330 return expiry
331
332 def get_token(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100333 return self.get_auth()[0]
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500334
335
336class KeystoneV2AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100337 """Provides authentication based on the Identity V2 API
338
339 The Keystone Identity V2 API defines both unscoped and project scoped
340 tokens. This auth provider only implements 'project'.
341 """
342
343 SCOPES = set(['project'])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500344
345 def _auth_client(self, auth_url):
346 return json_v2id.TokenClient(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100347 auth_url, disable_ssl_certificate_validation=self.dscv,
zhufl071e94c2016-07-12 10:26:34 +0800348 ca_certs=self.ca_certs, trace_requests=self.trace_requests,
Matthew Treinish74514402016-09-01 11:44:57 -0400349 http_timeout=self.http_timeout, proxy_url=self.proxy_url)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500350
351 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100352 """Auth parameters to be passed to the token request
353
354 All fields available in Credentials are passed to the token request.
355 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500356 return dict(
357 user=self.credentials.username,
358 password=self.credentials.password,
359 tenant=self.credentials.tenant_name,
360 auth_data=True)
361
362 def _fill_credentials(self, auth_data_body):
363 tenant = auth_data_body['token']['tenant']
364 user = auth_data_body['user']
365 if self.credentials.tenant_name is None:
366 self.credentials.tenant_name = tenant['name']
367 if self.credentials.tenant_id is None:
368 self.credentials.tenant_id = tenant['id']
369 if self.credentials.username is None:
370 self.credentials.username = user['name']
371 if self.credentials.user_id is None:
372 self.credentials.user_id = user['id']
373
374 def base_url(self, filters, auth_data=None):
375 """Base URL from catalog
376
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600377 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900378
379 Filters can be:
380
381 - service: service type name such as compute, image, etc.
382 - region: service region name
383 - name: service name, only if service exists
384 - endpoint_type: type of endpoint such as
385 adminURL, publicURL, internalURL
386 - api_version: the version of api used to replace catalog version
387 - skip_path: skips the suffix path of the url and uses base URL
388
389 :rtype: string
390 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500391 """
392 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100393 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500394 token, _auth_data = auth_data
395 service = filters.get('service')
396 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600397 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500398 endpoint_type = filters.get('endpoint_type', 'publicURL')
399
400 if service is None:
401 raise exceptions.EndpointNotFound("No service provided")
402
403 _base_url = None
404 for ep in _auth_data['serviceCatalog']:
405 if ep["type"] == service:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600406 if name is not None and ep["name"] != name:
407 continue
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500408 for _ep in ep['endpoints']:
409 if region is not None and _ep['region'] == region:
410 _base_url = _ep.get(endpoint_type)
411 if not _base_url:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600412 # No region or name matching, use the first
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500413 _base_url = ep['endpoints'][0].get(endpoint_type)
414 break
415 if _base_url is None:
Ken'ichi Ohmichib6cf83a2016-03-02 17:56:45 -0800416 raise exceptions.EndpointNotFound(
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600417 "service: %s, region: %s, endpoint_type: %s, name: %s" %
418 (service, region, endpoint_type, name))
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500419 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500420
421 def is_expired(self, auth_data):
422 _, access = auth_data
423 expiry = self._parse_expiry_time(access['token']['expires'])
424 return (expiry - self.token_expiry_threshold <=
425 datetime.datetime.utcnow())
426
427
428class KeystoneV3AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100429 """Provides authentication based on the Identity V3 API"""
430
431 SCOPES = set(['project', 'domain', 'unscoped', None])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500432
433 def _auth_client(self, auth_url):
434 return json_v3id.V3TokenClient(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100435 auth_url, disable_ssl_certificate_validation=self.dscv,
zhufl071e94c2016-07-12 10:26:34 +0800436 ca_certs=self.ca_certs, trace_requests=self.trace_requests,
Matthew Treinish74514402016-09-01 11:44:57 -0400437 http_timeout=self.http_timeout, proxy_url=self.proxy_url)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500438
439 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100440 """Auth parameters to be passed to the token request
441
442 Fields available in Credentials are passed to the token request,
443 depending on the value of scope. Valid values for scope are: "project",
444 "domain". Any other string (e.g. "unscoped") or None will lead to an
445 unscoped token request.
446 """
447
448 auth_params = dict(
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500449 user_id=self.credentials.user_id,
450 username=self.credentials.username,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500451 user_domain_id=self.credentials.user_domain_id,
452 user_domain_name=self.credentials.user_domain_name,
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100453 password=self.credentials.password,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500454 auth_data=True)
455
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100456 if self.scope == 'project':
457 auth_params.update(
458 project_domain_id=self.credentials.project_domain_id,
459 project_domain_name=self.credentials.project_domain_name,
460 project_id=self.credentials.project_id,
461 project_name=self.credentials.project_name)
462
463 if self.scope == 'domain':
464 auth_params.update(
465 domain_id=self.credentials.domain_id,
466 domain_name=self.credentials.domain_name)
467
468 return auth_params
469
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500470 def _fill_credentials(self, auth_data_body):
471 # project or domain, depending on the scope
472 project = auth_data_body.get('project', None)
473 domain = auth_data_body.get('domain', None)
474 # user is always there
475 user = auth_data_body['user']
476 # Set project fields
477 if project is not None:
478 if self.credentials.project_name is None:
479 self.credentials.project_name = project['name']
480 if self.credentials.project_id is None:
481 self.credentials.project_id = project['id']
482 if self.credentials.project_domain_id is None:
483 self.credentials.project_domain_id = project['domain']['id']
484 if self.credentials.project_domain_name is None:
485 self.credentials.project_domain_name = (
486 project['domain']['name'])
487 # Set domain fields
488 if domain is not None:
489 if self.credentials.domain_id is None:
490 self.credentials.domain_id = domain['id']
491 if self.credentials.domain_name is None:
492 self.credentials.domain_name = domain['name']
493 # Set user fields
494 if self.credentials.username is None:
495 self.credentials.username = user['name']
496 if self.credentials.user_id is None:
497 self.credentials.user_id = user['id']
498 if self.credentials.user_domain_id is None:
499 self.credentials.user_domain_id = user['domain']['id']
500 if self.credentials.user_domain_name is None:
501 self.credentials.user_domain_name = user['domain']['name']
502
503 def base_url(self, filters, auth_data=None):
504 """Base URL from catalog
505
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100506 If scope is not 'project', it may be that there is not catalog in
507 the auth_data. In such case, as long as the requested service is
508 'identity', we can use the original auth URL to build the base_url.
509
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600510 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900511
512 Filters can be:
513
514 - service: service type name such as compute, image, etc.
515 - region: service region name
516 - name: service name, only if service exists
517 - endpoint_type: type of endpoint such as
518 adminURL, publicURL, internalURL
519 - api_version: the version of api used to replace catalog version
520 - skip_path: skips the suffix path of the url and uses base URL
521
522 :rtype: string
523 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500524 """
525 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100526 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500527 token, _auth_data = auth_data
528 service = filters.get('service')
529 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600530 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500531 endpoint_type = filters.get('endpoint_type', 'public')
532
533 if service is None:
534 raise exceptions.EndpointNotFound("No service provided")
535
536 if 'URL' in endpoint_type:
537 endpoint_type = endpoint_type.replace('URL', '')
538 _base_url = None
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100539 catalog = _auth_data.get('catalog', [])
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100540
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500541 # Select entries with matching service type
542 service_catalog = [ep for ep in catalog if ep['type'] == service]
Masayuki Igawa0c0f0142017-04-10 17:22:02 +0900543 if service_catalog:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600544 if name is not None:
545 service_catalog = (
546 [ep for ep in service_catalog if ep['name'] == name])
Masayuki Igawa0c0f0142017-04-10 17:22:02 +0900547 if service_catalog:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600548 service_catalog = service_catalog[0]['endpoints']
549 else:
550 raise exceptions.EndpointNotFound(name)
551 else:
552 service_catalog = service_catalog[0]['endpoints']
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500553 else:
Masayuki Igawa0c0f0142017-04-10 17:22:02 +0900554 if not catalog and service == 'identity':
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100555 # NOTE(andreaf) If there's no catalog at all and the service
556 # is identity, it's a valid use case. Having a non-empty
557 # catalog with no identity in it is not valid instead.
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100558 msg = ('Got an empty catalog. Scope: %s. '
559 'Falling back to configured URL for %s: %s')
560 LOG.debug(msg, self.scope, service, self.auth_url)
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100561 return apply_url_filters(self.auth_url, filters)
562 else:
563 # No matching service
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100564 msg = ('No matching service found in the catalog.\n'
565 'Scope: %s, Credentials: %s\n'
566 'Auth data: %s\n'
567 'Service: %s, Region: %s, endpoint_type: %s\n'
568 'Catalog: %s')
569 raise exceptions.EndpointNotFound(msg % (
570 self.scope, self.credentials, _auth_data, service, region,
571 endpoint_type, catalog))
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500572 # Filter by endpoint type (interface)
573 filtered_catalog = [ep for ep in service_catalog if
574 ep['interface'] == endpoint_type]
Masayuki Igawa0c0f0142017-04-10 17:22:02 +0900575 if not filtered_catalog:
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500576 # No matching type, keep all and try matching by region at least
577 filtered_catalog = service_catalog
578 # Filter by region
579 filtered_catalog = [ep for ep in filtered_catalog if
580 ep['region'] == region]
Masayuki Igawa0c0f0142017-04-10 17:22:02 +0900581 if not filtered_catalog:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600582 # No matching region (or name), take the first endpoint
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500583 filtered_catalog = [service_catalog[0]]
584 # There should be only one match. If not take the first.
585 _base_url = filtered_catalog[0].get('url', None)
586 if _base_url is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100587 raise exceptions.EndpointNotFound(service)
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500588 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500589
590 def is_expired(self, auth_data):
591 _, access = auth_data
592 expiry = self._parse_expiry_time(access['expires_at'])
593 return (expiry - self.token_expiry_threshold <=
594 datetime.datetime.utcnow())
595
596
597def is_identity_version_supported(identity_version):
598 return identity_version in IDENTITY_VERSION
599
600
601def get_credentials(auth_url, fill_in=True, identity_version='v2',
602 disable_ssl_certificate_validation=None, ca_certs=None,
Andrea Frittolicb94b5e2017-10-23 16:53:34 +0200603 trace_requests=None, http_timeout=None, proxy_url=None,
604 **kwargs):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500605 """Builds a credentials object based on the configured auth_version
606
607 :param auth_url (string): Full URI of the OpenStack Identity API(Keystone)
608 which is used to fetch the token from Identity service.
609 :param fill_in (boolean): obtain a token and fill in all credential
610 details provided by the identity service. When fill_in is not
611 specified, credentials are not validated. Validation can be invoked
612 by invoking ``is_valid()``
613 :param identity_version (string): identity API version is used to
614 select the matching auth provider and credentials class
615 :param disable_ssl_certificate_validation: whether to enforce SSL
616 certificate validation in SSL API requests to the auth system
617 :param ca_certs: CA certificate bundle for validation of certificates
618 in SSL API requests to the auth system
619 :param trace_requests: trace in log API requests to the auth system
zhufl071e94c2016-07-12 10:26:34 +0800620 :param http_timeout: timeout in seconds to wait for the http request to
621 return
Andrea Frittolicb94b5e2017-10-23 16:53:34 +0200622 :param proxy_url: URL of HTTP(s) proxy used when fill_in is True
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500623 :param kwargs (dict): Dict of credential key/value pairs
624
625 Examples:
626
627 Returns credentials from the provided parameters:
628 >>> get_credentials(username='foo', password='bar')
629
630 Returns credentials including IDs:
631 >>> get_credentials(username='foo', password='bar', fill_in=True)
632 """
633 if not is_identity_version_supported(identity_version):
634 raise exceptions.InvalidIdentityVersion(
635 identity_version=identity_version)
636
637 credential_class, auth_provider_class = IDENTITY_VERSION.get(
638 identity_version)
639
640 creds = credential_class(**kwargs)
641 # Fill in the credentials fields that were not specified
642 if fill_in:
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100643 dscv = disable_ssl_certificate_validation
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500644 auth_provider = auth_provider_class(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100645 creds, auth_url, disable_ssl_certificate_validation=dscv,
zhufl071e94c2016-07-12 10:26:34 +0800646 ca_certs=ca_certs, trace_requests=trace_requests,
Andrea Frittolicb94b5e2017-10-23 16:53:34 +0200647 http_timeout=http_timeout, proxy_url=proxy_url)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500648 creds = auth_provider.fill_credentials()
649 return creds
650
651
652class Credentials(object):
653 """Set of credentials for accessing OpenStack services
654
655 ATTRIBUTES: list of valid class attributes representing credentials.
656 """
657
658 ATTRIBUTES = []
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100659 COLLISIONS = []
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500660
661 def __init__(self, **kwargs):
662 """Enforce the available attributes at init time (only).
663
664 Additional attributes can still be set afterwards if tests need
665 to do so.
666 """
667 self._initial = kwargs
668 self._apply_credentials(kwargs)
669
670 def _apply_credentials(self, attr):
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100671 for (key1, key2) in self.COLLISIONS:
672 val1 = attr.get(key1)
673 val2 = attr.get(key2)
674 if val1 and val2 and val1 != val2:
675 msg = ('Cannot have conflicting values for %s and %s' %
676 (key1, key2))
677 raise exceptions.InvalidCredentials(msg)
Joe H. Rahmea72f2c62016-07-11 16:28:19 +0200678 for key in attr:
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500679 if key in self.ATTRIBUTES:
680 setattr(self, key, attr[key])
681 else:
682 msg = '%s is not a valid attr for %s' % (key, self.__class__)
683 raise exceptions.InvalidCredentials(msg)
684
685 def __str__(self):
686 """Represent only attributes included in self.ATTRIBUTES"""
687 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
688 _repr = dict((k, getattr(self, k)) for k in attrs)
689 return str(_repr)
690
691 def __eq__(self, other):
692 """Credentials are equal if attributes in self.ATTRIBUTES are equal"""
693 return str(self) == str(other)
694
Ji-Wei79f5efd2016-07-14 16:58:43 +0800695 def __ne__(self, other):
696 """Contrary to the __eq__"""
697 return not self.__eq__(other)
698
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500699 def __getattr__(self, key):
700 # If an attribute is set, __getattr__ is not invoked
701 # If an attribute is not set, and it is a known one, return None
702 if key in self.ATTRIBUTES:
703 return None
704 else:
705 raise AttributeError
706
707 def __delitem__(self, key):
708 # For backwards compatibility, support dict behaviour
709 if key in self.ATTRIBUTES:
710 delattr(self, key)
711 else:
712 raise AttributeError
713
714 def get(self, item, default=None):
715 # In this patch act as dict for backward compatibility
716 try:
717 return getattr(self, item)
718 except AttributeError:
719 return default
720
721 def get_init_attributes(self):
722 return self._initial.keys()
723
724 def is_valid(self):
725 raise NotImplementedError
726
727 def reset(self):
728 # First delete all known attributes
729 for key in self.ATTRIBUTES:
730 if getattr(self, key) is not None:
731 delattr(self, key)
732 # Then re-apply initial setup
733 self._apply_credentials(self._initial)
734
735
736class KeystoneV2Credentials(Credentials):
737
738 ATTRIBUTES = ['username', 'password', 'tenant_name', 'user_id',
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100739 'tenant_id', 'project_id', 'project_name']
740 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
741
742 def __str__(self):
743 """Represent only attributes included in self.ATTRIBUTES"""
744 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
745 _repr = dict((k, getattr(self, k)) for k in attrs)
746 return str(_repr)
747
748 def __setattr__(self, key, value):
749 # NOTE(andreaf) In order to ease the migration towards 'project' we
750 # support v2 credentials configured with 'project' and translate it
751 # to tenant on the fly. The original kwargs are stored for clients
752 # that may rely on them. We also set project when tenant is defined
753 # so clients can rely on project being part of credentials.
754 parent = super(KeystoneV2Credentials, self)
755 # for project_* set tenant only
756 if key == 'project_id':
757 parent.__setattr__('tenant_id', value)
758 elif key == 'project_name':
759 parent.__setattr__('tenant_name', value)
760 if key == 'tenant_id':
761 parent.__setattr__('project_id', value)
762 elif key == 'tenant_name':
763 parent.__setattr__('project_name', value)
764 # trigger default behaviour for all attributes
765 parent.__setattr__(key, value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500766
767 def is_valid(self):
768 """Check of credentials (no API call)
769
770 Minimum set of valid credentials, are username and password.
771 Tenant is optional.
772 """
773 return None not in (self.username, self.password)
774
775
776class KeystoneV3Credentials(Credentials):
777 """Credentials suitable for the Keystone Identity V3 API"""
778
779 ATTRIBUTES = ['domain_id', 'domain_name', 'password', 'username',
780 'project_domain_id', 'project_domain_name', 'project_id',
781 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id',
782 'user_domain_name', 'user_id']
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100783 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
John Warrenb10c6ca2016-02-26 15:32:37 -0500784
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500785 def __setattr__(self, key, value):
786 parent = super(KeystoneV3Credentials, self)
787 # for tenant_* set both project and tenant
788 if key == 'tenant_id':
789 parent.__setattr__('project_id', value)
790 elif key == 'tenant_name':
791 parent.__setattr__('project_name', value)
792 # for project_* set both project and tenant
793 if key == 'project_id':
794 parent.__setattr__('tenant_id', value)
795 elif key == 'project_name':
796 parent.__setattr__('tenant_name', value)
797 # for *_domain_* set both user and project if not set yet
798 if key == 'user_domain_id':
799 if self.project_domain_id is None:
800 parent.__setattr__('project_domain_id', value)
801 if key == 'project_domain_id':
802 if self.user_domain_id is None:
803 parent.__setattr__('user_domain_id', value)
804 if key == 'user_domain_name':
805 if self.project_domain_name is None:
806 parent.__setattr__('project_domain_name', value)
807 if key == 'project_domain_name':
808 if self.user_domain_name is None:
809 parent.__setattr__('user_domain_name', value)
810 # support domain_name coming from config
811 if key == 'domain_name':
John Warrenb10c6ca2016-02-26 15:32:37 -0500812 if self.user_domain_name is None:
813 parent.__setattr__('user_domain_name', value)
814 if self.project_domain_name is None:
815 parent.__setattr__('project_domain_name', value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500816 # finally trigger default behaviour for all attributes
817 parent.__setattr__(key, value)
818
819 def is_valid(self):
820 """Check of credentials (no API call)
821
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100822 Valid combinations of v3 credentials (excluding token)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500823 - User id, password (optional domain)
824 - User name, password and its domain id/name
825 For the scope, valid combinations are:
826 - None
827 - Project id (optional domain)
828 - Project name and its domain id/name
829 - Domain id
830 - Domain name
831 """
832 valid_user_domain = any(
833 [self.user_domain_id is not None,
834 self.user_domain_name is not None])
835 valid_project_domain = any(
836 [self.project_domain_id is not None,
837 self.project_domain_name is not None])
838 valid_user = any(
839 [self.user_id is not None,
840 self.username is not None and valid_user_domain])
841 valid_project_scope = any(
842 [self.project_name is None and self.project_id is None,
843 self.project_id is not None,
844 self.project_name is not None and valid_project_domain])
845 valid_domain_scope = any(
846 [self.domain_id is None and self.domain_name is None,
847 self.domain_id or self.domain_name])
848 return all([self.password is not None,
849 valid_user,
850 valid_project_scope and valid_domain_scope])
851
852
853IDENTITY_VERSION = {'v2': (KeystoneV2Credentials, KeystoneV2AuthProvider),
854 'v3': (KeystoneV3Credentials, KeystoneV3AuthProvider)}