blob: 217c1ceeb6a2cd197f6ba59201d6e883b510713c [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,
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100263 ca_certs=None, trace_requests=None, scope='project'):
264 super(KeystoneAuthProvider, self).__init__(credentials, scope)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500265 self.dsvm = disable_ssl_certificate_validation
266 self.ca_certs = ca_certs
267 self.trace_requests = trace_requests
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100268 self.auth_url = auth_url
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500269 self.auth_client = self._auth_client(auth_url)
270
271 def _decorate_request(self, filters, method, url, headers=None, body=None,
272 auth_data=None):
273 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100274 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500275 token, _ = auth_data
276 base_url = self.base_url(filters=filters, auth_data=auth_data)
277 # build authenticated request
278 # returns new request, it does not touch the original values
279 _headers = copy.deepcopy(headers) if headers is not None else {}
280 _headers['X-Auth-Token'] = str(token)
281 if url is None or url == "":
282 _url = base_url
283 else:
284 # Join base URL and url, and remove multiple contiguous slashes
285 _url = "/".join([base_url, url])
286 parts = [x for x in urlparse.urlparse(_url)]
287 parts[2] = re.sub("/{2,}", "/", parts[2])
288 _url = urlparse.urlunparse(parts)
289 # no change to method or body
290 return str(_url), _headers, body
291
292 @abc.abstractmethod
293 def _auth_client(self):
294 return
295
296 @abc.abstractmethod
297 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100298 """Auth parameters to be passed to the token request
299
300 By default all fields available in Credentials are passed to the
301 token request. Scope may affect this.
302 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500303 return
304
305 def _get_auth(self):
306 # Bypasses the cache
307 auth_func = getattr(self.auth_client, 'get_token')
308 auth_params = self._auth_params()
309
310 # returns token, auth_data
311 token, auth_data = auth_func(**auth_params)
312 return token, auth_data
313
314 def _parse_expiry_time(self, expiry_string):
315 expiry = None
316 for date_format in self.EXPIRY_DATE_FORMATS:
317 try:
318 expiry = datetime.datetime.strptime(
319 expiry_string, date_format)
320 except ValueError:
321 pass
322 if expiry is None:
323 raise ValueError(
324 "time data '{data}' does not match any of the"
325 "expected formats: {formats}".format(
326 data=expiry_string, formats=self.EXPIRY_DATE_FORMATS))
327 return expiry
328
329 def get_token(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100330 return self.get_auth()[0]
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500331
332
333class KeystoneV2AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100334 """Provides authentication based on the Identity V2 API
335
336 The Keystone Identity V2 API defines both unscoped and project scoped
337 tokens. This auth provider only implements 'project'.
338 """
339
340 SCOPES = set(['project'])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500341
342 def _auth_client(self, auth_url):
343 return json_v2id.TokenClient(
344 auth_url, disable_ssl_certificate_validation=self.dsvm,
345 ca_certs=self.ca_certs, trace_requests=self.trace_requests)
346
347 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100348 """Auth parameters to be passed to the token request
349
350 All fields available in Credentials are passed to the token request.
351 """
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500352 return dict(
353 user=self.credentials.username,
354 password=self.credentials.password,
355 tenant=self.credentials.tenant_name,
356 auth_data=True)
357
358 def _fill_credentials(self, auth_data_body):
359 tenant = auth_data_body['token']['tenant']
360 user = auth_data_body['user']
361 if self.credentials.tenant_name is None:
362 self.credentials.tenant_name = tenant['name']
363 if self.credentials.tenant_id is None:
364 self.credentials.tenant_id = tenant['id']
365 if self.credentials.username is None:
366 self.credentials.username = user['name']
367 if self.credentials.user_id is None:
368 self.credentials.user_id = user['id']
369
370 def base_url(self, filters, auth_data=None):
371 """Base URL from catalog
372
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600373 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900374
375 Filters can be:
376
377 - service: service type name such as compute, image, etc.
378 - region: service region name
379 - name: service name, only if service exists
380 - endpoint_type: type of endpoint such as
381 adminURL, publicURL, internalURL
382 - api_version: the version of api used to replace catalog version
383 - skip_path: skips the suffix path of the url and uses base URL
384
385 :rtype: string
386 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500387 """
388 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100389 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500390 token, _auth_data = auth_data
391 service = filters.get('service')
392 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600393 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500394 endpoint_type = filters.get('endpoint_type', 'publicURL')
395
396 if service is None:
397 raise exceptions.EndpointNotFound("No service provided")
398
399 _base_url = None
400 for ep in _auth_data['serviceCatalog']:
401 if ep["type"] == service:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600402 if name is not None and ep["name"] != name:
403 continue
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500404 for _ep in ep['endpoints']:
405 if region is not None and _ep['region'] == region:
406 _base_url = _ep.get(endpoint_type)
407 if not _base_url:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600408 # No region or name matching, use the first
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500409 _base_url = ep['endpoints'][0].get(endpoint_type)
410 break
411 if _base_url is None:
Ken'ichi Ohmichib6cf83a2016-03-02 17:56:45 -0800412 raise exceptions.EndpointNotFound(
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600413 "service: %s, region: %s, endpoint_type: %s, name: %s" %
414 (service, region, endpoint_type, name))
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500415 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500416
417 def is_expired(self, auth_data):
418 _, access = auth_data
419 expiry = self._parse_expiry_time(access['token']['expires'])
420 return (expiry - self.token_expiry_threshold <=
421 datetime.datetime.utcnow())
422
423
424class KeystoneV3AuthProvider(KeystoneAuthProvider):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100425 """Provides authentication based on the Identity V3 API"""
426
427 SCOPES = set(['project', 'domain', 'unscoped', None])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500428
429 def _auth_client(self, auth_url):
430 return json_v3id.V3TokenClient(
431 auth_url, disable_ssl_certificate_validation=self.dsvm,
432 ca_certs=self.ca_certs, trace_requests=self.trace_requests)
433
434 def _auth_params(self):
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100435 """Auth parameters to be passed to the token request
436
437 Fields available in Credentials are passed to the token request,
438 depending on the value of scope. Valid values for scope are: "project",
439 "domain". Any other string (e.g. "unscoped") or None will lead to an
440 unscoped token request.
441 """
442
443 auth_params = dict(
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500444 user_id=self.credentials.user_id,
445 username=self.credentials.username,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500446 user_domain_id=self.credentials.user_domain_id,
447 user_domain_name=self.credentials.user_domain_name,
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100448 password=self.credentials.password,
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500449 auth_data=True)
450
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100451 if self.scope == 'project':
452 auth_params.update(
453 project_domain_id=self.credentials.project_domain_id,
454 project_domain_name=self.credentials.project_domain_name,
455 project_id=self.credentials.project_id,
456 project_name=self.credentials.project_name)
457
458 if self.scope == 'domain':
459 auth_params.update(
460 domain_id=self.credentials.domain_id,
461 domain_name=self.credentials.domain_name)
462
463 return auth_params
464
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500465 def _fill_credentials(self, auth_data_body):
466 # project or domain, depending on the scope
467 project = auth_data_body.get('project', None)
468 domain = auth_data_body.get('domain', None)
469 # user is always there
470 user = auth_data_body['user']
471 # Set project fields
472 if project is not None:
473 if self.credentials.project_name is None:
474 self.credentials.project_name = project['name']
475 if self.credentials.project_id is None:
476 self.credentials.project_id = project['id']
477 if self.credentials.project_domain_id is None:
478 self.credentials.project_domain_id = project['domain']['id']
479 if self.credentials.project_domain_name is None:
480 self.credentials.project_domain_name = (
481 project['domain']['name'])
482 # Set domain fields
483 if domain is not None:
484 if self.credentials.domain_id is None:
485 self.credentials.domain_id = domain['id']
486 if self.credentials.domain_name is None:
487 self.credentials.domain_name = domain['name']
488 # Set user fields
489 if self.credentials.username is None:
490 self.credentials.username = user['name']
491 if self.credentials.user_id is None:
492 self.credentials.user_id = user['id']
493 if self.credentials.user_domain_id is None:
494 self.credentials.user_domain_id = user['domain']['id']
495 if self.credentials.user_domain_name is None:
496 self.credentials.user_domain_name = user['domain']['name']
497
498 def base_url(self, filters, auth_data=None):
499 """Base URL from catalog
500
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100501 If scope is not 'project', it may be that there is not catalog in
502 the auth_data. In such case, as long as the requested service is
503 'identity', we can use the original auth URL to build the base_url.
504
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600505 :param filters: Used to filter results
Masayuki Igawaeb11b252016-06-10 10:40:02 +0900506
507 Filters can be:
508
509 - service: service type name such as compute, image, etc.
510 - region: service region name
511 - name: service name, only if service exists
512 - endpoint_type: type of endpoint such as
513 adminURL, publicURL, internalURL
514 - api_version: the version of api used to replace catalog version
515 - skip_path: skips the suffix path of the url and uses base URL
516
517 :rtype: string
518 :return: url with filters applied
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500519 """
520 if auth_data is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100521 auth_data = self.get_auth()
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500522 token, _auth_data = auth_data
523 service = filters.get('service')
524 region = filters.get('region')
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600525 name = filters.get('name')
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500526 endpoint_type = filters.get('endpoint_type', 'public')
527
528 if service is None:
529 raise exceptions.EndpointNotFound("No service provided")
530
531 if 'URL' in endpoint_type:
532 endpoint_type = endpoint_type.replace('URL', '')
533 _base_url = None
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100534 catalog = _auth_data.get('catalog', [])
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500535 # Select entries with matching service type
536 service_catalog = [ep for ep in catalog if ep['type'] == service]
537 if len(service_catalog) > 0:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600538 if name is not None:
539 service_catalog = (
540 [ep for ep in service_catalog if ep['name'] == name])
541 if len(service_catalog) > 0:
542 service_catalog = service_catalog[0]['endpoints']
543 else:
544 raise exceptions.EndpointNotFound(name)
545 else:
546 service_catalog = service_catalog[0]['endpoints']
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500547 else:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100548 if len(catalog) == 0 and service == 'identity':
549 # NOTE(andreaf) If there's no catalog at all and the service
550 # is identity, it's a valid use case. Having a non-empty
551 # catalog with no identity in it is not valid instead.
552 return apply_url_filters(self.auth_url, filters)
553 else:
554 # No matching service
555 raise exceptions.EndpointNotFound(service)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500556 # Filter by endpoint type (interface)
557 filtered_catalog = [ep for ep in service_catalog if
558 ep['interface'] == endpoint_type]
559 if len(filtered_catalog) == 0:
560 # No matching type, keep all and try matching by region at least
561 filtered_catalog = service_catalog
562 # Filter by region
563 filtered_catalog = [ep for ep in filtered_catalog if
564 ep['region'] == region]
565 if len(filtered_catalog) == 0:
Eric Wehrmeister54c7bd42016-02-24 11:11:07 -0600566 # No matching region (or name), take the first endpoint
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500567 filtered_catalog = [service_catalog[0]]
568 # There should be only one match. If not take the first.
569 _base_url = filtered_catalog[0].get('url', None)
570 if _base_url is None:
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100571 raise exceptions.EndpointNotFound(service)
Brant Knudsonf2d1f572016-04-11 15:02:01 -0500572 return apply_url_filters(_base_url, filters)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500573
574 def is_expired(self, auth_data):
575 _, access = auth_data
576 expiry = self._parse_expiry_time(access['expires_at'])
577 return (expiry - self.token_expiry_threshold <=
578 datetime.datetime.utcnow())
579
580
581def is_identity_version_supported(identity_version):
582 return identity_version in IDENTITY_VERSION
583
584
585def get_credentials(auth_url, fill_in=True, identity_version='v2',
586 disable_ssl_certificate_validation=None, ca_certs=None,
587 trace_requests=None, **kwargs):
588 """Builds a credentials object based on the configured auth_version
589
590 :param auth_url (string): Full URI of the OpenStack Identity API(Keystone)
591 which is used to fetch the token from Identity service.
592 :param fill_in (boolean): obtain a token and fill in all credential
593 details provided by the identity service. When fill_in is not
594 specified, credentials are not validated. Validation can be invoked
595 by invoking ``is_valid()``
596 :param identity_version (string): identity API version is used to
597 select the matching auth provider and credentials class
598 :param disable_ssl_certificate_validation: whether to enforce SSL
599 certificate validation in SSL API requests to the auth system
600 :param ca_certs: CA certificate bundle for validation of certificates
601 in SSL API requests to the auth system
602 :param trace_requests: trace in log API requests to the auth system
603 :param kwargs (dict): Dict of credential key/value pairs
604
605 Examples:
606
607 Returns credentials from the provided parameters:
608 >>> get_credentials(username='foo', password='bar')
609
610 Returns credentials including IDs:
611 >>> get_credentials(username='foo', password='bar', fill_in=True)
612 """
613 if not is_identity_version_supported(identity_version):
614 raise exceptions.InvalidIdentityVersion(
615 identity_version=identity_version)
616
617 credential_class, auth_provider_class = IDENTITY_VERSION.get(
618 identity_version)
619
620 creds = credential_class(**kwargs)
621 # Fill in the credentials fields that were not specified
622 if fill_in:
623 dsvm = disable_ssl_certificate_validation
624 auth_provider = auth_provider_class(
625 creds, auth_url, disable_ssl_certificate_validation=dsvm,
626 ca_certs=ca_certs, trace_requests=trace_requests)
627 creds = auth_provider.fill_credentials()
628 return creds
629
630
631class Credentials(object):
632 """Set of credentials for accessing OpenStack services
633
634 ATTRIBUTES: list of valid class attributes representing credentials.
635 """
636
637 ATTRIBUTES = []
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100638 COLLISIONS = []
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500639
640 def __init__(self, **kwargs):
641 """Enforce the available attributes at init time (only).
642
643 Additional attributes can still be set afterwards if tests need
644 to do so.
645 """
646 self._initial = kwargs
647 self._apply_credentials(kwargs)
648
649 def _apply_credentials(self, attr):
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100650 for (key1, key2) in self.COLLISIONS:
651 val1 = attr.get(key1)
652 val2 = attr.get(key2)
653 if val1 and val2 and val1 != val2:
654 msg = ('Cannot have conflicting values for %s and %s' %
655 (key1, key2))
656 raise exceptions.InvalidCredentials(msg)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500657 for key in attr.keys():
658 if key in self.ATTRIBUTES:
659 setattr(self, key, attr[key])
660 else:
661 msg = '%s is not a valid attr for %s' % (key, self.__class__)
662 raise exceptions.InvalidCredentials(msg)
663
664 def __str__(self):
665 """Represent only attributes included in self.ATTRIBUTES"""
666 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
667 _repr = dict((k, getattr(self, k)) for k in attrs)
668 return str(_repr)
669
670 def __eq__(self, other):
671 """Credentials are equal if attributes in self.ATTRIBUTES are equal"""
672 return str(self) == str(other)
673
674 def __getattr__(self, key):
675 # If an attribute is set, __getattr__ is not invoked
676 # If an attribute is not set, and it is a known one, return None
677 if key in self.ATTRIBUTES:
678 return None
679 else:
680 raise AttributeError
681
682 def __delitem__(self, key):
683 # For backwards compatibility, support dict behaviour
684 if key in self.ATTRIBUTES:
685 delattr(self, key)
686 else:
687 raise AttributeError
688
689 def get(self, item, default=None):
690 # In this patch act as dict for backward compatibility
691 try:
692 return getattr(self, item)
693 except AttributeError:
694 return default
695
696 def get_init_attributes(self):
697 return self._initial.keys()
698
699 def is_valid(self):
700 raise NotImplementedError
701
702 def reset(self):
703 # First delete all known attributes
704 for key in self.ATTRIBUTES:
705 if getattr(self, key) is not None:
706 delattr(self, key)
707 # Then re-apply initial setup
708 self._apply_credentials(self._initial)
709
710
711class KeystoneV2Credentials(Credentials):
712
713 ATTRIBUTES = ['username', 'password', 'tenant_name', 'user_id',
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100714 'tenant_id', 'project_id', 'project_name']
715 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
716
717 def __str__(self):
718 """Represent only attributes included in self.ATTRIBUTES"""
719 attrs = [attr for attr in self.ATTRIBUTES if attr is not 'password']
720 _repr = dict((k, getattr(self, k)) for k in attrs)
721 return str(_repr)
722
723 def __setattr__(self, key, value):
724 # NOTE(andreaf) In order to ease the migration towards 'project' we
725 # support v2 credentials configured with 'project' and translate it
726 # to tenant on the fly. The original kwargs are stored for clients
727 # that may rely on them. We also set project when tenant is defined
728 # so clients can rely on project being part of credentials.
729 parent = super(KeystoneV2Credentials, self)
730 # for project_* set tenant only
731 if key == 'project_id':
732 parent.__setattr__('tenant_id', value)
733 elif key == 'project_name':
734 parent.__setattr__('tenant_name', value)
735 if key == 'tenant_id':
736 parent.__setattr__('project_id', value)
737 elif key == 'tenant_name':
738 parent.__setattr__('project_name', value)
739 # trigger default behaviour for all attributes
740 parent.__setattr__(key, value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500741
742 def is_valid(self):
743 """Check of credentials (no API call)
744
745 Minimum set of valid credentials, are username and password.
746 Tenant is optional.
747 """
748 return None not in (self.username, self.password)
749
750
751class KeystoneV3Credentials(Credentials):
752 """Credentials suitable for the Keystone Identity V3 API"""
753
754 ATTRIBUTES = ['domain_id', 'domain_name', 'password', 'username',
755 'project_domain_id', 'project_domain_name', 'project_id',
756 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id',
757 'user_domain_name', 'user_id']
Andrea Frittoli (andreaf)52deb8b2016-05-18 19:14:22 +0100758 COLLISIONS = [('project_name', 'tenant_name'), ('project_id', 'tenant_id')]
John Warrenb10c6ca2016-02-26 15:32:37 -0500759
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500760 def __setattr__(self, key, value):
761 parent = super(KeystoneV3Credentials, self)
762 # for tenant_* set both project and tenant
763 if key == 'tenant_id':
764 parent.__setattr__('project_id', value)
765 elif key == 'tenant_name':
766 parent.__setattr__('project_name', value)
767 # for project_* set both project and tenant
768 if key == 'project_id':
769 parent.__setattr__('tenant_id', value)
770 elif key == 'project_name':
771 parent.__setattr__('tenant_name', value)
772 # for *_domain_* set both user and project if not set yet
773 if key == 'user_domain_id':
774 if self.project_domain_id is None:
775 parent.__setattr__('project_domain_id', value)
776 if key == 'project_domain_id':
777 if self.user_domain_id is None:
778 parent.__setattr__('user_domain_id', value)
779 if key == 'user_domain_name':
780 if self.project_domain_name is None:
781 parent.__setattr__('project_domain_name', value)
782 if key == 'project_domain_name':
783 if self.user_domain_name is None:
784 parent.__setattr__('user_domain_name', value)
785 # support domain_name coming from config
786 if key == 'domain_name':
John Warrenb10c6ca2016-02-26 15:32:37 -0500787 if self.user_domain_name is None:
788 parent.__setattr__('user_domain_name', value)
789 if self.project_domain_name is None:
790 parent.__setattr__('project_domain_name', value)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500791 # finally trigger default behaviour for all attributes
792 parent.__setattr__(key, value)
793
794 def is_valid(self):
795 """Check of credentials (no API call)
796
Andrea Frittoli (andreaf)3e82af72016-05-05 22:53:38 +0100797 Valid combinations of v3 credentials (excluding token)
Matthew Treinish9e26ca82016-02-23 11:43:20 -0500798 - User id, password (optional domain)
799 - User name, password and its domain id/name
800 For the scope, valid combinations are:
801 - None
802 - Project id (optional domain)
803 - Project name and its domain id/name
804 - Domain id
805 - Domain name
806 """
807 valid_user_domain = any(
808 [self.user_domain_id is not None,
809 self.user_domain_name is not None])
810 valid_project_domain = any(
811 [self.project_domain_id is not None,
812 self.project_domain_name is not None])
813 valid_user = any(
814 [self.user_id is not None,
815 self.username is not None and valid_user_domain])
816 valid_project_scope = any(
817 [self.project_name is None and self.project_id is None,
818 self.project_id is not None,
819 self.project_name is not None and valid_project_domain])
820 valid_domain_scope = any(
821 [self.domain_id is None and self.domain_name is None,
822 self.domain_id or self.domain_name])
823 return all([self.password is not None,
824 valid_user,
825 valid_project_scope and valid_domain_scope])
826
827
828IDENTITY_VERSION = {'v2': (KeystoneV2Credentials, KeystoneV2AuthProvider),
829 'v3': (KeystoneV3Credentials, KeystoneV3AuthProvider)}