blob: fc4359778b8e3e3809dff467b9db5a35e386cfce [file] [log] [blame]
Maru Newbyb096d9f2015-03-09 18:54:54 +00001# Copyright 2014 Hewlett-Packard Development Company, L.P.
2# All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License"); you may
5# not use this file except in compliance with the License. You may obtain
6# a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13# License for the specific language governing permissions and limitations
14# under the License.
15
16import abc
17import copy
18import datetime
19import exceptions
20import re
21import urlparse
22
Ihar Hrachyshkac695f9f2015-02-26 23:26:41 +010023from oslo_log import log as logging
Maru Newbyb096d9f2015-03-09 18:54:54 +000024import six
25
Ken'ichi Ohmichi060eb892015-09-16 10:04:32 +000026from tempest_lib.services.identity.v2 import token_client as json_v2id
27from tempest_lib.services.identity.v3 import token_client as json_v3id
Maru Newbyb096d9f2015-03-09 18:54:54 +000028
29LOG = logging.getLogger(__name__)
30
31
32@six.add_metaclass(abc.ABCMeta)
33class AuthProvider(object):
34 """
35 Provide authentication
36 """
37
38 def __init__(self, credentials):
39 """
40 :param credentials: credentials for authentication
41 """
42 if self.check_credentials(credentials):
43 self.credentials = credentials
44 else:
45 raise TypeError("Invalid credentials")
46 self.cache = None
47 self.alt_auth_data = None
48 self.alt_part = None
49
50 def __str__(self):
51 return "Creds :{creds}, cached auth data: {cache}".format(
52 creds=self.credentials, cache=self.cache)
53
54 @abc.abstractmethod
55 def _decorate_request(self, filters, method, url, headers=None, body=None,
56 auth_data=None):
57 """
58 Decorate request with authentication data
59 """
60 return
61
62 @abc.abstractmethod
63 def _get_auth(self):
64 return
65
66 @abc.abstractmethod
67 def _fill_credentials(self, auth_data_body):
68 return
69
70 def fill_credentials(self):
71 """
72 Fill credentials object with data from auth
73 """
74 auth_data = self.get_auth()
75 self._fill_credentials(auth_data[1])
76 return self.credentials
77
78 @classmethod
79 def check_credentials(cls, credentials):
80 """
81 Verify credentials are valid.
82 """
83 return isinstance(credentials, Credentials) and credentials.is_valid()
84
85 @property
86 def auth_data(self):
87 return self.get_auth()
88
89 @auth_data.deleter
90 def auth_data(self):
91 self.clear_auth()
92
93 def get_auth(self):
94 """
95 Returns auth from cache if available, else auth first
96 """
97 if self.cache is None or self.is_expired(self.cache):
98 self.set_auth()
99 return self.cache
100
101 def set_auth(self):
102 """
103 Forces setting auth, ignores cache if it exists.
104 Refills credentials
105 """
106 self.cache = self._get_auth()
107 self._fill_credentials(self.cache[1])
108
109 def clear_auth(self):
110 """
111 Can be called to clear the access cache so that next request
112 will fetch a new token and base_url.
113 """
114 self.cache = None
115 self.credentials.reset()
116
117 @abc.abstractmethod
118 def is_expired(self, auth_data):
119 return
120
121 def auth_request(self, method, url, headers=None, body=None, filters=None):
122 """
123 Obtains auth data and decorates a request with that.
124 :param method: HTTP method of the request
125 :param url: relative URL of the request (path)
126 :param headers: HTTP headers of the request
127 :param body: HTTP body in case of POST / PUT
128 :param filters: select a base URL out of the catalog
129 :returns a Tuple (url, headers, body)
130 """
131 orig_req = dict(url=url, headers=headers, body=body)
132
133 auth_url, auth_headers, auth_body = self._decorate_request(
134 filters, method, url, headers, body)
135 auth_req = dict(url=auth_url, headers=auth_headers, body=auth_body)
136
137 # Overwrite part if the request if it has been requested
138 if self.alt_part is not None:
139 if self.alt_auth_data is not None:
140 alt_url, alt_headers, alt_body = self._decorate_request(
141 filters, method, url, headers, body,
142 auth_data=self.alt_auth_data)
143 alt_auth_req = dict(url=alt_url, headers=alt_headers,
144 body=alt_body)
145 auth_req[self.alt_part] = alt_auth_req[self.alt_part]
146
147 else:
148 # If alt auth data is None, skip auth in the requested part
149 auth_req[self.alt_part] = orig_req[self.alt_part]
150
151 # Next auth request will be normal, unless otherwise requested
152 self.reset_alt_auth_data()
153
154 return auth_req['url'], auth_req['headers'], auth_req['body']
155
156 def reset_alt_auth_data(self):
157 """
158 Configure auth provider to provide valid authentication data
159 """
160 self.alt_part = None
161 self.alt_auth_data = None
162
163 def set_alt_auth_data(self, request_part, auth_data):
164 """
165 Configure auth provider to provide alt authentication data
166 on a part of the *next* auth_request. If credentials are None,
167 set invalid data.
168 :param request_part: request part to contain invalid auth: url,
169 headers, body
170 :param auth_data: alternative auth_data from which to get the
171 invalid data to be injected
172 """
173 self.alt_part = request_part
174 self.alt_auth_data = auth_data
175
176 @abc.abstractmethod
177 def base_url(self, filters, auth_data=None):
178 """
179 Extracts the base_url based on provided filters
180 """
181 return
182
183
184class KeystoneAuthProvider(AuthProvider):
185
186 token_expiry_threshold = datetime.timedelta(seconds=60)
187
188 def __init__(self, credentials, auth_url,
189 disable_ssl_certificate_validation=None,
190 ca_certs=None, trace_requests=None):
191 super(KeystoneAuthProvider, self).__init__(credentials)
192 self.dsvm = disable_ssl_certificate_validation
193 self.ca_certs = ca_certs
194 self.trace_requests = trace_requests
195 self.auth_client = self._auth_client(auth_url)
196
197 def _decorate_request(self, filters, method, url, headers=None, body=None,
198 auth_data=None):
199 if auth_data is None:
200 auth_data = self.auth_data
201 token, _ = auth_data
202 base_url = self.base_url(filters=filters, auth_data=auth_data)
203 # build authenticated request
204 # returns new request, it does not touch the original values
205 _headers = copy.deepcopy(headers) if headers is not None else {}
206 _headers['X-Auth-Token'] = str(token)
207 if url is None or url == "":
208 _url = base_url
209 else:
210 # Join base URL and url, and remove multiple contiguous slashes
211 _url = "/".join([base_url, url])
212 parts = [x for x in urlparse.urlparse(_url)]
213 parts[2] = re.sub("/{2,}", "/", parts[2])
214 _url = urlparse.urlunparse(parts)
215 # no change to method or body
216 return str(_url), _headers, body
217
218 @abc.abstractmethod
219 def _auth_client(self):
220 return
221
222 @abc.abstractmethod
223 def _auth_params(self):
224 return
225
226 def _get_auth(self):
227 # Bypasses the cache
228 auth_func = getattr(self.auth_client, 'get_token')
229 auth_params = self._auth_params()
230
231 # returns token, auth_data
232 token, auth_data = auth_func(**auth_params)
233 return token, auth_data
234
235 def get_token(self):
236 return self.auth_data[0]
237
238
239class KeystoneV2AuthProvider(KeystoneAuthProvider):
240
241 EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
242
243 def _auth_client(self, auth_url):
244 return json_v2id.TokenClientJSON(
245 auth_url, disable_ssl_certificate_validation=self.dsvm,
246 ca_certs=self.ca_certs, trace_requests=self.trace_requests)
247
248 def _auth_params(self):
249 return dict(
250 user=self.credentials.username,
251 password=self.credentials.password,
252 tenant=self.credentials.tenant_name,
253 auth_data=True)
254
255 def _fill_credentials(self, auth_data_body):
256 tenant = auth_data_body['token']['tenant']
257 user = auth_data_body['user']
258 if self.credentials.tenant_name is None:
259 self.credentials.tenant_name = tenant['name']
260 if self.credentials.tenant_id is None:
261 self.credentials.tenant_id = tenant['id']
262 if self.credentials.username is None:
263 self.credentials.username = user['name']
264 if self.credentials.user_id is None:
265 self.credentials.user_id = user['id']
266
267 def base_url(self, filters, auth_data=None):
268 """
269 Filters can be:
270 - service: compute, image, etc
271 - region: the service region
272 - endpoint_type: adminURL, publicURL, internalURL
273 - api_version: replace catalog version with this
274 - skip_path: take just the base URL
275 """
276 if auth_data is None:
277 auth_data = self.auth_data
278 token, _auth_data = auth_data
279 service = filters.get('service')
280 region = filters.get('region')
281 endpoint_type = filters.get('endpoint_type', 'publicURL')
282
283 if service is None:
284 raise exceptions.EndpointNotFound("No service provided")
285
286 _base_url = None
287 for ep in _auth_data['serviceCatalog']:
288 if ep["type"] == service:
289 for _ep in ep['endpoints']:
290 if region is not None and _ep['region'] == region:
291 _base_url = _ep.get(endpoint_type)
292 if not _base_url:
293 # No region matching, use the first
294 _base_url = ep['endpoints'][0].get(endpoint_type)
295 break
296 if _base_url is None:
297 raise exceptions.EndpointNotFound(service)
298
299 parts = urlparse.urlparse(_base_url)
300 if filters.get('api_version', None) is not None:
301 path = "/" + filters['api_version']
302 noversion_path = "/".join(parts.path.split("/")[2:])
303 if noversion_path != "":
304 path += "/" + noversion_path
305 _base_url = _base_url.replace(parts.path, path)
306 if filters.get('skip_path', None) is not None and parts.path != '':
307 _base_url = _base_url.replace(parts.path, "/")
308
309 return _base_url
310
311 def is_expired(self, auth_data):
312 _, access = auth_data
313 expiry = datetime.datetime.strptime(access['token']['expires'],
314 self.EXPIRY_DATE_FORMAT)
315 return expiry - self.token_expiry_threshold <= \
316 datetime.datetime.utcnow()
317
318
319class KeystoneV3AuthProvider(KeystoneAuthProvider):
320
321 EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
322
323 def _auth_client(self, auth_url):
324 return json_v3id.V3TokenClientJSON(
325 auth_url, disable_ssl_certificate_validation=self.dsvm,
326 ca_certs=self.ca_certs, trace_requests=self.trace_requests)
327
328 def _auth_params(self):
329 return dict(
Maru Newby5690a352015-03-13 18:46:40 +0000330 user_id=self.credentials.user_id,
331 username=self.credentials.username,
Maru Newbyb096d9f2015-03-09 18:54:54 +0000332 password=self.credentials.password,
Maru Newby5690a352015-03-13 18:46:40 +0000333 project_id=self.credentials.project_id,
334 project_name=self.credentials.project_name,
335 user_domain_id=self.credentials.user_domain_id,
336 user_domain_name=self.credentials.user_domain_name,
337 project_domain_id=self.credentials.project_domain_id,
338 project_domain_name=self.credentials.project_domain_name,
339 domain_id=self.credentials.domain_id,
340 domain_name=self.credentials.domain_name,
Maru Newbyb096d9f2015-03-09 18:54:54 +0000341 auth_data=True)
342
343 def _fill_credentials(self, auth_data_body):
344 # project or domain, depending on the scope
345 project = auth_data_body.get('project', None)
346 domain = auth_data_body.get('domain', None)
347 # user is always there
348 user = auth_data_body['user']
349 # Set project fields
350 if project is not None:
351 if self.credentials.project_name is None:
352 self.credentials.project_name = project['name']
353 if self.credentials.project_id is None:
354 self.credentials.project_id = project['id']
355 if self.credentials.project_domain_id is None:
356 self.credentials.project_domain_id = project['domain']['id']
357 if self.credentials.project_domain_name is None:
358 self.credentials.project_domain_name = \
359 project['domain']['name']
360 # Set domain fields
361 if domain is not None:
362 if self.credentials.domain_id is None:
363 self.credentials.domain_id = domain['id']
364 if self.credentials.domain_name is None:
365 self.credentials.domain_name = domain['name']
366 # Set user fields
367 if self.credentials.username is None:
368 self.credentials.username = user['name']
369 if self.credentials.user_id is None:
370 self.credentials.user_id = user['id']
371 if self.credentials.user_domain_id is None:
372 self.credentials.user_domain_id = user['domain']['id']
373 if self.credentials.user_domain_name is None:
374 self.credentials.user_domain_name = user['domain']['name']
375
376 def base_url(self, filters, auth_data=None):
377 """
378 Filters can be:
379 - service: compute, image, etc
380 - region: the service region
381 - endpoint_type: adminURL, publicURL, internalURL
382 - api_version: replace catalog version with this
383 - skip_path: take just the base URL
384 """
385 if auth_data is None:
386 auth_data = self.auth_data
387 token, _auth_data = auth_data
388 service = filters.get('service')
389 region = filters.get('region')
390 endpoint_type = filters.get('endpoint_type', 'public')
391
392 if service is None:
393 raise exceptions.EndpointNotFound("No service provided")
394
395 if 'URL' in endpoint_type:
396 endpoint_type = endpoint_type.replace('URL', '')
397 _base_url = None
398 catalog = _auth_data['catalog']
399 # Select entries with matching service type
400 service_catalog = [ep for ep in catalog if ep['type'] == service]
401 if len(service_catalog) > 0:
402 service_catalog = service_catalog[0]['endpoints']
403 else:
404 # No matching service
405 raise exceptions.EndpointNotFound(service)
406 # Filter by endpoint type (interface)
407 filtered_catalog = [ep for ep in service_catalog if
408 ep['interface'] == endpoint_type]
409 if len(filtered_catalog) == 0:
410 # No matching type, keep all and try matching by region at least
411 filtered_catalog = service_catalog
412 # Filter by region
413 filtered_catalog = [ep for ep in filtered_catalog if
414 ep['region'] == region]
415 if len(filtered_catalog) == 0:
416 # No matching region, take the first endpoint
417 filtered_catalog = [service_catalog[0]]
418 # There should be only one match. If not take the first.
419 _base_url = filtered_catalog[0].get('url', None)
420 if _base_url is None:
421 raise exceptions.EndpointNotFound(service)
422
423 parts = urlparse.urlparse(_base_url)
424 if filters.get('api_version', None) is not None:
425 path = "/" + filters['api_version']
426 noversion_path = "/".join(parts.path.split("/")[2:])
427 if noversion_path != "":
428 path += "/" + noversion_path
429 _base_url = _base_url.replace(parts.path, path)
430 if filters.get('skip_path', None) is not None:
431 _base_url = _base_url.replace(parts.path, "/")
432
433 return _base_url
434
435 def is_expired(self, auth_data):
436 _, access = auth_data
437 expiry = datetime.datetime.strptime(access['expires_at'],
438 self.EXPIRY_DATE_FORMAT)
439 return expiry - self.token_expiry_threshold <= \
440 datetime.datetime.utcnow()
441
442
443def is_identity_version_supported(identity_version):
444 return identity_version in IDENTITY_VERSION
445
446
Maru Newby5690a352015-03-13 18:46:40 +0000447def get_credentials(auth_url, fill_in=True, identity_version='v2',
448 disable_ssl_certificate_validation=None, ca_certs=None,
449 trace_requests=None, **kwargs):
Maru Newbyb096d9f2015-03-09 18:54:54 +0000450 """
451 Builds a credentials object based on the configured auth_version
452
453 :param auth_url (string): Full URI of the OpenStack Identity API(Keystone)
454 which is used to fetch the token from Identity service.
455 :param fill_in (boolean): obtain a token and fill in all credential
456 details provided by the identity service. When fill_in is not
457 specified, credentials are not validated. Validation can be invoked
458 by invoking ``is_valid()``
459 :param identity_version (string): identity API version is used to
460 select the matching auth provider and credentials class
Maru Newby5690a352015-03-13 18:46:40 +0000461 :param disable_ssl_certificate_validation: whether to enforce SSL
462 certificate validation in SSL API requests to the auth system
463 :param ca_certs: CA certificate bundle for validation of certificates
464 in SSL API requests to the auth system
465 :param trace_requests: trace in log API requests to the auth system
Maru Newbyb096d9f2015-03-09 18:54:54 +0000466 :param kwargs (dict): Dict of credential key/value pairs
467
468 Examples:
469
470 Returns credentials from the provided parameters:
471 >>> get_credentials(username='foo', password='bar')
472
473 Returns credentials including IDs:
474 >>> get_credentials(username='foo', password='bar', fill_in=True)
475 """
476 if not is_identity_version_supported(identity_version):
477 raise exceptions.InvalidIdentityVersion(
478 identity_version=identity_version)
479
480 credential_class, auth_provider_class = IDENTITY_VERSION.get(
481 identity_version)
482
483 creds = credential_class(**kwargs)
484 # Fill in the credentials fields that were not specified
485 if fill_in:
Maru Newby5690a352015-03-13 18:46:40 +0000486 dsvm = disable_ssl_certificate_validation
487 auth_provider = auth_provider_class(
488 creds, auth_url, disable_ssl_certificate_validation=dsvm,
489 ca_certs=ca_certs, trace_requests=trace_requests)
Maru Newbyb096d9f2015-03-09 18:54:54 +0000490 creds = auth_provider.fill_credentials()
491 return creds
492
493
494class Credentials(object):
495 """
496 Set of credentials for accessing OpenStack services
497
498 ATTRIBUTES: list of valid class attributes representing credentials.
499 """
500
501 ATTRIBUTES = []
502
503 def __init__(self, **kwargs):
504 """
505 Enforce the available attributes at init time (only).
506 Additional attributes can still be set afterwards if tests need
507 to do so.
508 """
509 self._initial = kwargs
510 self._apply_credentials(kwargs)
511
512 def _apply_credentials(self, attr):
513 for key in attr.keys():
514 if key in self.ATTRIBUTES:
515 setattr(self, key, attr[key])
516 else:
517 raise exceptions.InvalidCredentials
518
519 def __str__(self):
520 """
521 Represent only attributes included in self.ATTRIBUTES
522 """
523 _repr = dict((k, getattr(self, k)) for k in self.ATTRIBUTES)
524 return str(_repr)
525
526 def __eq__(self, other):
527 """
528 Credentials are equal if attributes in self.ATTRIBUTES are equal
529 """
530 return str(self) == str(other)
531
532 def __getattr__(self, key):
533 # If an attribute is set, __getattr__ is not invoked
534 # If an attribute is not set, and it is a known one, return None
535 if key in self.ATTRIBUTES:
536 return None
537 else:
538 raise AttributeError
539
540 def __delitem__(self, key):
541 # For backwards compatibility, support dict behaviour
542 if key in self.ATTRIBUTES:
543 delattr(self, key)
544 else:
545 raise AttributeError
546
547 def get(self, item, default):
548 # In this patch act as dict for backward compatibility
549 try:
550 return getattr(self, item)
551 except AttributeError:
552 return default
553
554 def get_init_attributes(self):
555 return self._initial.keys()
556
557 def is_valid(self):
558 raise NotImplementedError
559
560 def reset(self):
561 # First delete all known attributes
562 for key in self.ATTRIBUTES:
563 if getattr(self, key) is not None:
564 delattr(self, key)
565 # Then re-apply initial setup
566 self._apply_credentials(self._initial)
567
568
569class KeystoneV2Credentials(Credentials):
570
571 ATTRIBUTES = ['username', 'password', 'tenant_name', 'user_id',
572 'tenant_id']
573
574 def is_valid(self):
575 """
576 Minimum set of valid credentials, are username and password.
577 Tenant is optional.
578 """
579 return None not in (self.username, self.password)
580
581
582class KeystoneV3Credentials(Credentials):
583 """
584 Credentials suitable for the Keystone Identity V3 API
585 """
586
Maru Newby5690a352015-03-13 18:46:40 +0000587 ATTRIBUTES = ['domain_id', 'domain_name', 'password', 'username',
Maru Newbyb096d9f2015-03-09 18:54:54 +0000588 'project_domain_id', 'project_domain_name', 'project_id',
589 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id',
590 'user_domain_name', 'user_id']
591
592 def __setattr__(self, key, value):
593 parent = super(KeystoneV3Credentials, self)
594 # for tenant_* set both project and tenant
595 if key == 'tenant_id':
596 parent.__setattr__('project_id', value)
597 elif key == 'tenant_name':
598 parent.__setattr__('project_name', value)
599 # for project_* set both project and tenant
600 if key == 'project_id':
601 parent.__setattr__('tenant_id', value)
602 elif key == 'project_name':
603 parent.__setattr__('tenant_name', value)
604 # for *_domain_* set both user and project if not set yet
605 if key == 'user_domain_id':
606 if self.project_domain_id is None:
607 parent.__setattr__('project_domain_id', value)
608 if key == 'project_domain_id':
609 if self.user_domain_id is None:
610 parent.__setattr__('user_domain_id', value)
611 if key == 'user_domain_name':
612 if self.project_domain_name is None:
613 parent.__setattr__('project_domain_name', value)
614 if key == 'project_domain_name':
615 if self.user_domain_name is None:
616 parent.__setattr__('user_domain_name', value)
617 # support domain_name coming from config
618 if key == 'domain_name':
619 parent.__setattr__('user_domain_name', value)
620 parent.__setattr__('project_domain_name', value)
621 # finally trigger default behaviour for all attributes
622 parent.__setattr__(key, value)
623
624 def is_valid(self):
625 """
626 Valid combinations of v3 credentials (excluding token, scope)
627 - User id, password (optional domain)
628 - User name, password and its domain id/name
629 For the scope, valid combinations are:
630 - None
631 - Project id (optional domain)
632 - Project name and its domain id/name
Maru Newby5690a352015-03-13 18:46:40 +0000633 - Domain id
634 - Domain name
Maru Newbyb096d9f2015-03-09 18:54:54 +0000635 """
636 valid_user_domain = any(
637 [self.user_domain_id is not None,
638 self.user_domain_name is not None])
639 valid_project_domain = any(
640 [self.project_domain_id is not None,
641 self.project_domain_name is not None])
642 valid_user = any(
643 [self.user_id is not None,
644 self.username is not None and valid_user_domain])
Maru Newby5690a352015-03-13 18:46:40 +0000645 valid_project_scope = any(
Maru Newbyb096d9f2015-03-09 18:54:54 +0000646 [self.project_name is None and self.project_id is None,
647 self.project_id is not None,
648 self.project_name is not None and valid_project_domain])
Maru Newby5690a352015-03-13 18:46:40 +0000649 valid_domain_scope = any(
650 [self.domain_id is None and self.domain_name is None,
651 self.domain_id or self.domain_name])
652 return all([self.password is not None,
653 valid_user,
654 valid_project_scope and valid_domain_scope])
Maru Newbyb096d9f2015-03-09 18:54:54 +0000655
656
657IDENTITY_VERSION = {'v2': (KeystoneV2Credentials, KeystoneV2AuthProvider),
658 'v3': (KeystoneV3Credentials, KeystoneV3AuthProvider)}