blob: 83aa405f3ffd5869310b4c37f59e3f694a9c64c8 [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',
264 http_timeout=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
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100270 self.auth_url = auth_url
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500271 self.auth_client = self._auth_client(auth_url)
272
273 def _decorate_request(self, filters, method, url, headers=None, body=None,
274 auth_data=None):
275 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100276 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500277 token, _ = auth_data
278 base_url = self.base_url(filters=filters, auth_data=auth_data)
279 # build authenticated request
280 # returns new request, it does not touch the original values
281 _headers = copy.deepcopy(headers) if headers is not None else {}
282 _headers['X-Auth-Token'] = str(token)
283 if url is None or url == "":
284 _url = base_url
285 else:
286 # Join base URL and url, and remove multiple contiguous slashes
287 _url = "/".join([base_url, url])
288 parts = [x for x in urlparse.urlparse(_url)]
289 parts[2] = re.sub("/{2,}", "/", parts[2])
290 _url = urlparse.urlunparse(parts)
291 # no change to method or body
292 return str(_url), _headers, body
293
294 @abc.abstractmethod
295 def _auth_client(self):
296 return
297
298 @abc.abstractmethod
299 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100300 """Auth parameters to be passed to the token request
301
302 By default all fields available in Credentials are passed to the
303 token request. Scope may affect this.
304 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500305 return
306
307 def _get_auth(self):
308 # Bypasses the cache
309 auth_func = getattr(self.auth_client, 'get_token')
310 auth_params = self._auth_params()
311
312 # returns token, auth_data
313 token, auth_data = auth_func(**auth_params)
314 return token, auth_data
315
316 def _parse_expiry_time(self, expiry_string):
317 expiry = None
318 for date_format in self.EXPIRY_DATE_FORMATS:
319 try:
320 expiry = datetime.datetime.strptime(
321 expiry_string, date_format)
322 except ValueError:
323 pass
324 if expiry is None:
325 raise ValueError(
326 "time data '{data}' does not match any of the"
327 "expected formats: {formats}".format(
328 data=expiry_string, formats=self.EXPIRY_DATE_FORMATS))
329 return expiry
330
331 def get_token(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100332 return self.get_auth()[0]
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500333
334
335class KeystoneV2AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100336 """Provides authentication based on the Identity V2 API
337
338 The Keystone Identity V2 API defines both unscoped and project scoped
339 tokens. This auth provider only implements 'project'.
340 """
341
342 SCOPES = set(['project'])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500343
344 def _auth_client(self, auth_url):
345 return json_v2id.TokenClient(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100346 auth_url, disable_ssl_certificate_validation=self.dscv,
zhufl071e94c2016-07-12 10:26:34 +0800347 ca_certs=self.ca_certs, trace_requests=self.trace_requests,
348 http_timeout=self.http_timeout)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500349
350 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100351 """Auth parameters to be passed to the token request
352
353 All fields available in Credentials are passed to the token request.
354 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500355 return dict(
356 user=self.credentials.username,
357 password=self.credentials.password,
358 tenant=self.credentials.tenant_name,
359 auth_data=True)
360
361 def _fill_credentials(self, auth_data_body):
362 tenant = auth_data_body['token']['tenant']
363 user = auth_data_body['user']
364 if self.credentials.tenant_name is None:
365 self.credentials.tenant_name = tenant['name']
366 if self.credentials.tenant_id is None:
367 self.credentials.tenant_id = tenant['id']
368 if self.credentials.username is None:
369 self.credentials.username = user['name']
370 if self.credentials.user_id is None:
371 self.credentials.user_id = user['id']
372
373 def base_url(self, filters, auth_data=None):
374 """Base URL from catalog
375
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600376 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900377
378 Filters can be:
379
380 - service: service type name such as compute, image, etc.
381 - region: service region name
382 - name: service name, only if service exists
383 - endpoint_type: type of endpoint such as
384 adminURL, publicURL, internalURL
385 - api_version: the version of api used to replace catalog version
386 - skip_path: skips the suffix path of the url and uses base URL
387
388 :rtype: string
389 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500390 """
391 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100392 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500393 token, _auth_data = auth_data
394 service = filters.get('service')
395 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600396 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500397 endpoint_type = filters.get('endpoint_type', 'publicURL')
398
399 if service is None:
400 raise exceptions.EndpointNotFound("No service provided")
401
402 _base_url = None
403 for ep in _auth_data['serviceCatalog']:
404 if ep["type"] == service:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600405 if name is not None and ep["name"] != name:
406 continue
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500407 for _ep in ep['endpoints']:
408 if region is not None and _ep['region'] == region:
409 _base_url = _ep.get(endpoint_type)
410 if not _base_url:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600411 # No region or name matching, use the first
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500412 _base_url = ep['endpoints'][0].get(endpoint_type)
413 break
414 if _base_url is None:
Ken'ichi Ohmichib6cf83a2016-03-02 17:56:45 -0800415 raise exceptions.EndpointNotFound(
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600416 "service: %s, region: %s, endpoint_type: %s, name: %s" %
417 (service, region, endpoint_type, name))
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500418 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500419
420 def is_expired(self, auth_data):
421 _, access = auth_data
422 expiry = self._parse_expiry_time(access['token']['expires'])
423 return (expiry - self.token_expiry_threshold <=
424 datetime.datetime.utcnow())
425
426
427class KeystoneV3AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100428 """Provides authentication based on the Identity V3 API"""
429
430 SCOPES = set(['project', 'domain', 'unscoped', None])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500431
432 def _auth_client(self, auth_url):
433 return json_v3id.V3TokenClient(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100434 auth_url, disable_ssl_certificate_validation=self.dscv,
zhufl071e94c2016-07-12 10:26:34 +0800435 ca_certs=self.ca_certs, trace_requests=self.trace_requests,
436 http_timeout=self.http_timeout)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500437
438 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100439 """Auth parameters to be passed to the token request
440
441 Fields available in Credentials are passed to the token request,
442 depending on the value of scope. Valid values for scope are: "project",
443 "domain". Any other string (e.g. "unscoped") or None will lead to an
444 unscoped token request.
445 """
446
447 auth_params = dict(
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500448 user_id=self.credentials.user_id,
449 username=self.credentials.username,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500450 user_domain_id=self.credentials.user_domain_id,
451 user_domain_name=self.credentials.user_domain_name,
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100452 password=self.credentials.password,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500453 auth_data=True)
454
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100455 if self.scope == 'project':
456 auth_params.update(
457 project_domain_id=self.credentials.project_domain_id,
458 project_domain_name=self.credentials.project_domain_name,
459 project_id=self.credentials.project_id,
460 project_name=self.credentials.project_name)
461
462 if self.scope == 'domain':
463 auth_params.update(
464 domain_id=self.credentials.domain_id,
465 domain_name=self.credentials.domain_name)
466
467 return auth_params
468
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500469 def _fill_credentials(self, auth_data_body):
470 # project or domain, depending on the scope
471 project = auth_data_body.get('project', None)
472 domain = auth_data_body.get('domain', None)
473 # user is always there
474 user = auth_data_body['user']
475 # Set project fields
476 if project is not None:
477 if self.credentials.project_name is None:
478 self.credentials.project_name = project['name']
479 if self.credentials.project_id is None:
480 self.credentials.project_id = project['id']
481 if self.credentials.project_domain_id is None:
482 self.credentials.project_domain_id = project['domain']['id']
483 if self.credentials.project_domain_name is None:
484 self.credentials.project_domain_name = (
485 project['domain']['name'])
486 # Set domain fields
487 if domain is not None:
488 if self.credentials.domain_id is None:
489 self.credentials.domain_id = domain['id']
490 if self.credentials.domain_name is None:
491 self.credentials.domain_name = domain['name']
492 # Set user fields
493 if self.credentials.username is None:
494 self.credentials.username = user['name']
495 if self.credentials.user_id is None:
496 self.credentials.user_id = user['id']
497 if self.credentials.user_domain_id is None:
498 self.credentials.user_domain_id = user['domain']['id']
499 if self.credentials.user_domain_name is None:
500 self.credentials.user_domain_name = user['domain']['name']
501
502 def base_url(self, filters, auth_data=None):
503 """Base URL from catalog
504
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100505 If scope is not 'project', it may be that there is not catalog in
506 the auth_data. In such case, as long as the requested service is
507 'identity', we can use the original auth URL to build the base_url.
508
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600509 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900510
511 Filters can be:
512
513 - service: service type name such as compute, image, etc.
514 - region: service region name
515 - name: service name, only if service exists
516 - endpoint_type: type of endpoint such as
517 adminURL, publicURL, internalURL
518 - api_version: the version of api used to replace catalog version
519 - skip_path: skips the suffix path of the url and uses base URL
520
521 :rtype: string
522 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500523 """
524 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100525 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500526 token, _auth_data = auth_data
527 service = filters.get('service')
528 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600529 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500530 endpoint_type = filters.get('endpoint_type', 'public')
531
532 if service is None:
533 raise exceptions.EndpointNotFound("No service provided")
534
535 if 'URL' in endpoint_type:
536 endpoint_type = endpoint_type.replace('URL', '')
537 _base_url = None
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100538 catalog = _auth_data.get('catalog', [])
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100539
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500540 # Select entries with matching service type
541 service_catalog = [ep for ep in catalog if ep['type'] == service]
542 if len(service_catalog) > 0:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600543 if name is not None:
544 service_catalog = (
545 [ep for ep in service_catalog if ep['name'] == name])
546 if len(service_catalog) > 0:
547 service_catalog = service_catalog[0]['endpoints']
548 else:
549 raise exceptions.EndpointNotFound(name)
550 else:
551 service_catalog = service_catalog[0]['endpoints']
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500552 else:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100553 if len(catalog) == 0 and service == 'identity':
554 # NOTE(andreaf) If there's no catalog at all and the service
555 # is identity, it's a valid use case. Having a non-empty
556 # catalog with no identity in it is not valid instead.
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100557 msg = ('Got an empty catalog. Scope: %s. '
558 'Falling back to configured URL for %s: %s')
559 LOG.debug(msg, self.scope, service, self.auth_url)
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100560 return apply_url_filters(self.auth_url, filters)
561 else:
562 # No matching service
Andrea Frittoli (andreaf)100d18d2016-05-05 23:34:52 +0100563 msg = ('No matching service found in the catalog.\n'
564 'Scope: %s, Credentials: %s\n'
565 'Auth data: %s\n'
566 'Service: %s, Region: %s, endpoint_type: %s\n'
567 'Catalog: %s')
568 raise exceptions.EndpointNotFound(msg % (
569 self.scope, self.credentials, _auth_data, service, region,
570 endpoint_type, catalog))
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500571 # Filter by endpoint type (interface)
572 filtered_catalog = [ep for ep in service_catalog if
573 ep['interface'] == endpoint_type]
574 if len(filtered_catalog) == 0:
575 # No matching type, keep all and try matching by region at least
576 filtered_catalog = service_catalog
577 # Filter by region
578 filtered_catalog = [ep for ep in filtered_catalog if
579 ep['region'] == region]
580 if len(filtered_catalog) == 0:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600581 # No matching region (or name), take the first endpoint
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500582 filtered_catalog = [service_catalog[0]]
583 # There should be only one match. If not take the first.
584 _base_url = filtered_catalog[0].get('url', None)
585 if _base_url is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100586 raise exceptions.EndpointNotFound(service)
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500587 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500588
589 def is_expired(self, auth_data):
590 _, access = auth_data
591 expiry = self._parse_expiry_time(access['expires_at'])
592 return (expiry - self.token_expiry_threshold <=
593 datetime.datetime.utcnow())
594
595
596def is_identity_version_supported(identity_version):
597 return identity_version in IDENTITY_VERSION
598
599
600def get_credentials(auth_url, fill_in=True, identity_version='v2',
601 disable_ssl_certificate_validation=None, ca_certs=None,
zhufl071e94c2016-07-12 10:26:34 +0800602 trace_requests=None, http_timeout=None, **kwargs):
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500603 """Builds a credentials object based on the configured auth_version
604
605 :param auth_url (string): Full URI of the OpenStack Identity API(Keystone)
606 which is used to fetch the token from Identity service.
607 :param fill_in (boolean): obtain a token and fill in all credential
608 details provided by the identity service. When fill_in is not
609 specified, credentials are not validated. Validation can be invoked
610 by invoking ``is_valid()``
611 :param identity_version (string): identity API version is used to
612 select the matching auth provider and credentials class
613 :param disable_ssl_certificate_validation: whether to enforce SSL
614 certificate validation in SSL API requests to the auth system
615 :param ca_certs: CA certificate bundle for validation of certificates
616 in SSL API requests to the auth system
617 :param trace_requests: trace in log API requests to the auth system
zhufl071e94c2016-07-12 10:26:34 +0800618 :param http_timeout: timeout in seconds to wait for the http request to
619 return
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500620 :param kwargs (dict): Dict of credential key/value pairs
621
622 Examples:
623
624 Returns credentials from the provided parameters:
625 >>> get_credentials(username='foo', password='bar')
626
627 Returns credentials including IDs:
628 >>> get_credentials(username='foo', password='bar', fill_in=True)
629 """
630 if not is_identity_version_supported(identity_version):
631 raise exceptions.InvalidIdentityVersion(
632 identity_version=identity_version)
633
634 credential_class, auth_provider_class = IDENTITY_VERSION.get(
635 identity_version)
636
637 creds = credential_class(**kwargs)
638 # Fill in the credentials fields that were not specified
639 if fill_in:
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100640 dscv = disable_ssl_certificate_validation
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500641 auth_provider = auth_provider_class(
Andrea Frittoli (andreaf)97aa6042016-06-10 13:22:56 +0100642 creds, auth_url, disable_ssl_certificate_validation=dscv,
zhufl071e94c2016-07-12 10:26:34 +0800643 ca_certs=ca_certs, trace_requests=trace_requests,
644 http_timeout=http_timeout)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500645 creds = auth_provider.fill_credentials()
646 return creds
647
648
649class Credentials(object):
650 """Set of credentials for accessing OpenStack services
651
652 ATTRIBUTES: list of valid class attributes representing credentials.
653 """
654
655 ATTRIBUTES = []
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100656 COLLISIONS = []
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500657
658 def __init__(self, **kwargs):
659 """Enforce the available attributes at init time (only).
660
661 Additional attributes can still be set afterwards if tests need
662 to do so.
663 """
664 self._initial = kwargs
665 self._apply_credentials(kwargs)
666
667 def _apply_credentials(self, attr):
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100668 for (key1, key2) in self.COLLISIONS:
669 val1 = attr.get(key1)
670 val2 = attr.get(key2)
671 if val1 and val2 and val1 != val2:
672 msg = ('Cannot have conflicting values for %s and %s' %
673 (key1, key2))
674 raise exceptions.InvalidCredentials(msg)
Joe H. Rahmea72f2c62016-07-11 16:28:19 +0200675 for key in attr:
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500676 if key in self.ATTRIBUTES:
677 setattr(self, key, attr[key])
678 else:
679 msg = '%s is not a valid attr for %s' % (key, self.__class__)
680 raise exceptions.InvalidCredentials(msg)
681
682 def __str__(self):
683 """Represent only attributes included in self.ATTRIBUTES"""
684 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
685 _repr = dict((k, getattr(self, k)) for k in attrs)
686 return str(_repr)
687
688 def __eq__(self, other):
689 """Credentials are equal if attributes in self.ATTRIBUTES are equal"""
690 return str(self) == str(other)
691
Ji-Wei79f5efd2016-07-14 16:58:43 +0800692 def __ne__(self, other):
693 """Contrary to the __eq__"""
694 return not self.__eq__(other)
695
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500696 def __getattr__(self, key):
697 # If an attribute is set, __getattr__ is not invoked
698 # If an attribute is not set, and it is a known one, return None
699 if key in self.ATTRIBUTES:
700 return None
701 else:
702 raise AttributeError
703
704 def __delitem__(self, key):
705 # For backwards compatibility, support dict behaviour
706 if key in self.ATTRIBUTES:
707 delattr(self, key)
708 else:
709 raise AttributeError
710
711 def get(self, item, default=None):
712 # In this patch act as dict for backward compatibility
713 try:
714 return getattr(self, item)
715 except AttributeError:
716 return default
717
718 def get_init_attributes(self):
719 return self._initial.keys()
720
721 def is_valid(self):
722 raise NotImplementedError
723
724 def reset(self):
725 # First delete all known attributes
726 for key in self.ATTRIBUTES:
727 if getattr(self, key) is not None:
728 delattr(self, key)
729 # Then re-apply initial setup
730 self._apply_credentials(self._initial)
731
732
733class KeystoneV2Credentials(Credentials):
734
735 ATTRIBUTES = ['username', 'password', 'tenant_name', 'user_id',
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100736 'tenant_id', 'project_id', 'project_name']
737 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
738
739 def __str__(self):
740 """Represent only attributes included in self.ATTRIBUTES"""
741 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
742 _repr = dict((k, getattr(self, k)) for k in attrs)
743 return str(_repr)
744
745 def __setattr__(self, key, value):
746 # NOTE(andreaf) In order to ease the migration towards 'project' we
747 # support v2 credentials configured with 'project' and translate it
748 # to tenant on the fly. The original kwargs are stored for clients
749 # that may rely on them. We also set project when tenant is defined
750 # so clients can rely on project being part of credentials.
751 parent = super(KeystoneV2Credentials, self)
752 # for project_* set tenant only
753 if key == 'project_id':
754 parent.__setattr__('tenant_id', value)
755 elif key == 'project_name':
756 parent.__setattr__('tenant_name', value)
757 if key == 'tenant_id':
758 parent.__setattr__('project_id', value)
759 elif key == 'tenant_name':
760 parent.__setattr__('project_name', value)
761 # trigger default behaviour for all attributes
762 parent.__setattr__(key, value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500763
764 def is_valid(self):
765 """Check of credentials (no API call)
766
767 Minimum set of valid credentials, are username and password.
768 Tenant is optional.
769 """
770 return None not in (self.username, self.password)
771
772
773class KeystoneV3Credentials(Credentials):
774 """Credentials suitable for the Keystone Identity V3 API"""
775
776 ATTRIBUTES = ['domain_id', 'domain_name', 'password', 'username',
777 'project_domain_id', 'project_domain_name', 'project_id',
778 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id',
779 'user_domain_name', 'user_id']
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100780 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
John Warrenb10c6ca2016-02-26 15:32:37 -0500781
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500782 def __setattr__(self, key, value):
783 parent = super(KeystoneV3Credentials, self)
784 # for tenant_* set both project and tenant
785 if key == 'tenant_id':
786 parent.__setattr__('project_id', value)
787 elif key == 'tenant_name':
788 parent.__setattr__('project_name', value)
789 # for project_* set both project and tenant
790 if key == 'project_id':
791 parent.__setattr__('tenant_id', value)
792 elif key == 'project_name':
793 parent.__setattr__('tenant_name', value)
794 # for *_domain_* set both user and project if not set yet
795 if key == 'user_domain_id':
796 if self.project_domain_id is None:
797 parent.__setattr__('project_domain_id', value)
798 if key == 'project_domain_id':
799 if self.user_domain_id is None:
800 parent.__setattr__('user_domain_id', value)
801 if key == 'user_domain_name':
802 if self.project_domain_name is None:
803 parent.__setattr__('project_domain_name', value)
804 if key == 'project_domain_name':
805 if self.user_domain_name is None:
806 parent.__setattr__('user_domain_name', value)
807 # support domain_name coming from config
808 if key == 'domain_name':
John Warrenb10c6ca2016-02-26 15:32:37 -0500809 if self.user_domain_name is None:
810 parent.__setattr__('user_domain_name', value)
811 if self.project_domain_name is None:
812 parent.__setattr__('project_domain_name', value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500813 # finally trigger default behaviour for all attributes
814 parent.__setattr__(key, value)
815
816 def is_valid(self):
817 """Check of credentials (no API call)
818
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100819 Valid combinations of v3 credentials (excluding token)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500820 - User id, password (optional domain)
821 - User name, password and its domain id/name
822 For the scope, valid combinations are:
823 - None
824 - Project id (optional domain)
825 - Project name and its domain id/name
826 - Domain id
827 - Domain name
828 """
829 valid_user_domain = any(
830 [self.user_domain_id is not None,
831 self.user_domain_name is not None])
832 valid_project_domain = any(
833 [self.project_domain_id is not None,
834 self.project_domain_name is not None])
835 valid_user = any(
836 [self.user_id is not None,
837 self.username is not None and valid_user_domain])
838 valid_project_scope = any(
839 [self.project_name is None and self.project_id is None,
840 self.project_id is not None,
841 self.project_name is not None and valid_project_domain])
842 valid_domain_scope = any(
843 [self.domain_id is None and self.domain_name is None,
844 self.domain_id or self.domain_name])
845 return all([self.password is not None,
846 valid_user,
847 valid_project_scope and valid_domain_scope])
848
849
850IDENTITY_VERSION = {'v2': (KeystoneV2Credentials, KeystoneV2AuthProvider),
851 'v3': (KeystoneV3Credentials, KeystoneV3AuthProvider)}