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