blob: ceee2658082a073e6e411f1bb477457299bbba9a [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
25from neutron.openstack.common import log as logging
26from 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(
331 user=self.credentials.username,
332 password=self.credentials.password,
333 project=self.credentials.tenant_name,
334 user_domain=self.credentials.user_domain_name,
335 project_domain=self.credentials.project_domain_name,
336 auth_data=True)
337
338 def _fill_credentials(self, auth_data_body):
339 # project or domain, depending on the scope
340 project = auth_data_body.get('project', None)
341 domain = auth_data_body.get('domain', None)
342 # user is always there
343 user = auth_data_body['user']
344 # Set project fields
345 if project is not None:
346 if self.credentials.project_name is None:
347 self.credentials.project_name = project['name']
348 if self.credentials.project_id is None:
349 self.credentials.project_id = project['id']
350 if self.credentials.project_domain_id is None:
351 self.credentials.project_domain_id = project['domain']['id']
352 if self.credentials.project_domain_name is None:
353 self.credentials.project_domain_name = \
354 project['domain']['name']
355 # Set domain fields
356 if domain is not None:
357 if self.credentials.domain_id is None:
358 self.credentials.domain_id = domain['id']
359 if self.credentials.domain_name is None:
360 self.credentials.domain_name = domain['name']
361 # Set user fields
362 if self.credentials.username is None:
363 self.credentials.username = user['name']
364 if self.credentials.user_id is None:
365 self.credentials.user_id = user['id']
366 if self.credentials.user_domain_id is None:
367 self.credentials.user_domain_id = user['domain']['id']
368 if self.credentials.user_domain_name is None:
369 self.credentials.user_domain_name = user['domain']['name']
370
371 def base_url(self, filters, auth_data=None):
372 """
373 Filters can be:
374 - service: compute, image, etc
375 - region: the service region
376 - endpoint_type: adminURL, publicURL, internalURL
377 - api_version: replace catalog version with this
378 - skip_path: take just the base URL
379 """
380 if auth_data is None:
381 auth_data = self.auth_data
382 token, _auth_data = auth_data
383 service = filters.get('service')
384 region = filters.get('region')
385 endpoint_type = filters.get('endpoint_type', 'public')
386
387 if service is None:
388 raise exceptions.EndpointNotFound("No service provided")
389
390 if 'URL' in endpoint_type:
391 endpoint_type = endpoint_type.replace('URL', '')
392 _base_url = None
393 catalog = _auth_data['catalog']
394 # Select entries with matching service type
395 service_catalog = [ep for ep in catalog if ep['type'] == service]
396 if len(service_catalog) > 0:
397 service_catalog = service_catalog[0]['endpoints']
398 else:
399 # No matching service
400 raise exceptions.EndpointNotFound(service)
401 # Filter by endpoint type (interface)
402 filtered_catalog = [ep for ep in service_catalog if
403 ep['interface'] == endpoint_type]
404 if len(filtered_catalog) == 0:
405 # No matching type, keep all and try matching by region at least
406 filtered_catalog = service_catalog
407 # Filter by region
408 filtered_catalog = [ep for ep in filtered_catalog if
409 ep['region'] == region]
410 if len(filtered_catalog) == 0:
411 # No matching region, take the first endpoint
412 filtered_catalog = [service_catalog[0]]
413 # There should be only one match. If not take the first.
414 _base_url = filtered_catalog[0].get('url', None)
415 if _base_url is None:
416 raise exceptions.EndpointNotFound(service)
417
418 parts = urlparse.urlparse(_base_url)
419 if filters.get('api_version', None) is not None:
420 path = "/" + filters['api_version']
421 noversion_path = "/".join(parts.path.split("/")[2:])
422 if noversion_path != "":
423 path += "/" + noversion_path
424 _base_url = _base_url.replace(parts.path, path)
425 if filters.get('skip_path', None) is not None:
426 _base_url = _base_url.replace(parts.path, "/")
427
428 return _base_url
429
430 def is_expired(self, auth_data):
431 _, access = auth_data
432 expiry = datetime.datetime.strptime(access['expires_at'],
433 self.EXPIRY_DATE_FORMAT)
434 return expiry - self.token_expiry_threshold <= \
435 datetime.datetime.utcnow()
436
437
438def is_identity_version_supported(identity_version):
439 return identity_version in IDENTITY_VERSION
440
441
442def get_credentials(auth_url, fill_in=True, identity_version='v2', **kwargs):
443 """
444 Builds a credentials object based on the configured auth_version
445
446 :param auth_url (string): Full URI of the OpenStack Identity API(Keystone)
447 which is used to fetch the token from Identity service.
448 :param fill_in (boolean): obtain a token and fill in all credential
449 details provided by the identity service. When fill_in is not
450 specified, credentials are not validated. Validation can be invoked
451 by invoking ``is_valid()``
452 :param identity_version (string): identity API version is used to
453 select the matching auth provider and credentials class
454 :param kwargs (dict): Dict of credential key/value pairs
455
456 Examples:
457
458 Returns credentials from the provided parameters:
459 >>> get_credentials(username='foo', password='bar')
460
461 Returns credentials including IDs:
462 >>> get_credentials(username='foo', password='bar', fill_in=True)
463 """
464 if not is_identity_version_supported(identity_version):
465 raise exceptions.InvalidIdentityVersion(
466 identity_version=identity_version)
467
468 credential_class, auth_provider_class = IDENTITY_VERSION.get(
469 identity_version)
470
471 creds = credential_class(**kwargs)
472 # Fill in the credentials fields that were not specified
473 if fill_in:
474 auth_provider = auth_provider_class(creds, auth_url)
475 creds = auth_provider.fill_credentials()
476 return creds
477
478
479class Credentials(object):
480 """
481 Set of credentials for accessing OpenStack services
482
483 ATTRIBUTES: list of valid class attributes representing credentials.
484 """
485
486 ATTRIBUTES = []
487
488 def __init__(self, **kwargs):
489 """
490 Enforce the available attributes at init time (only).
491 Additional attributes can still be set afterwards if tests need
492 to do so.
493 """
494 self._initial = kwargs
495 self._apply_credentials(kwargs)
496
497 def _apply_credentials(self, attr):
498 for key in attr.keys():
499 if key in self.ATTRIBUTES:
500 setattr(self, key, attr[key])
501 else:
502 raise exceptions.InvalidCredentials
503
504 def __str__(self):
505 """
506 Represent only attributes included in self.ATTRIBUTES
507 """
508 _repr = dict((k, getattr(self, k)) for k in self.ATTRIBUTES)
509 return str(_repr)
510
511 def __eq__(self, other):
512 """
513 Credentials are equal if attributes in self.ATTRIBUTES are equal
514 """
515 return str(self) == str(other)
516
517 def __getattr__(self, key):
518 # If an attribute is set, __getattr__ is not invoked
519 # If an attribute is not set, and it is a known one, return None
520 if key in self.ATTRIBUTES:
521 return None
522 else:
523 raise AttributeError
524
525 def __delitem__(self, key):
526 # For backwards compatibility, support dict behaviour
527 if key in self.ATTRIBUTES:
528 delattr(self, key)
529 else:
530 raise AttributeError
531
532 def get(self, item, default):
533 # In this patch act as dict for backward compatibility
534 try:
535 return getattr(self, item)
536 except AttributeError:
537 return default
538
539 def get_init_attributes(self):
540 return self._initial.keys()
541
542 def is_valid(self):
543 raise NotImplementedError
544
545 def reset(self):
546 # First delete all known attributes
547 for key in self.ATTRIBUTES:
548 if getattr(self, key) is not None:
549 delattr(self, key)
550 # Then re-apply initial setup
551 self._apply_credentials(self._initial)
552
553
554class KeystoneV2Credentials(Credentials):
555
556 ATTRIBUTES = ['username', 'password', 'tenant_name', 'user_id',
557 'tenant_id']
558
559 def is_valid(self):
560 """
561 Minimum set of valid credentials, are username and password.
562 Tenant is optional.
563 """
564 return None not in (self.username, self.password)
565
566
567class KeystoneV3Credentials(Credentials):
568 """
569 Credentials suitable for the Keystone Identity V3 API
570 """
571
572 ATTRIBUTES = ['domain_name', 'password', 'tenant_name', 'username',
573 'project_domain_id', 'project_domain_name', 'project_id',
574 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id',
575 'user_domain_name', 'user_id']
576
577 def __setattr__(self, key, value):
578 parent = super(KeystoneV3Credentials, self)
579 # for tenant_* set both project and tenant
580 if key == 'tenant_id':
581 parent.__setattr__('project_id', value)
582 elif key == 'tenant_name':
583 parent.__setattr__('project_name', value)
584 # for project_* set both project and tenant
585 if key == 'project_id':
586 parent.__setattr__('tenant_id', value)
587 elif key == 'project_name':
588 parent.__setattr__('tenant_name', value)
589 # for *_domain_* set both user and project if not set yet
590 if key == 'user_domain_id':
591 if self.project_domain_id is None:
592 parent.__setattr__('project_domain_id', value)
593 if key == 'project_domain_id':
594 if self.user_domain_id is None:
595 parent.__setattr__('user_domain_id', value)
596 if key == 'user_domain_name':
597 if self.project_domain_name is None:
598 parent.__setattr__('project_domain_name', value)
599 if key == 'project_domain_name':
600 if self.user_domain_name is None:
601 parent.__setattr__('user_domain_name', value)
602 # support domain_name coming from config
603 if key == 'domain_name':
604 parent.__setattr__('user_domain_name', value)
605 parent.__setattr__('project_domain_name', value)
606 # finally trigger default behaviour for all attributes
607 parent.__setattr__(key, value)
608
609 def is_valid(self):
610 """
611 Valid combinations of v3 credentials (excluding token, scope)
612 - User id, password (optional domain)
613 - User name, password and its domain id/name
614 For the scope, valid combinations are:
615 - None
616 - Project id (optional domain)
617 - Project name and its domain id/name
618 """
619 valid_user_domain = any(
620 [self.user_domain_id is not None,
621 self.user_domain_name is not None])
622 valid_project_domain = any(
623 [self.project_domain_id is not None,
624 self.project_domain_name is not None])
625 valid_user = any(
626 [self.user_id is not None,
627 self.username is not None and valid_user_domain])
628 valid_project = any(
629 [self.project_name is None and self.project_id is None,
630 self.project_id is not None,
631 self.project_name is not None and valid_project_domain])
632 return all([self.password is not None, valid_user, valid_project])
633
634
635IDENTITY_VERSION = {'v2': (KeystoneV2Credentials, KeystoneV2AuthProvider),
636 'v3': (KeystoneV3Credentials, KeystoneV3AuthProvider)}