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