# Copyright 2014 Hewlett-Packard Development Company, L.P. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. import copy import datetime import exceptions import re import urlparse from tempest import config from tempest.services.identity.json import identity_client as json_id from tempest.services.identity.v3.json import identity_client as json_v3id from tempest.services.identity.v3.xml import identity_client as xml_v3id from tempest.services.identity.xml import identity_client as xml_id from tempest.openstack.common import log as logging CONF = config.CONF LOG = logging.getLogger(__name__) class AuthProvider(object): """ Provide authentication """ def __init__(self, credentials, client_type='tempest', interface=None): """ :param credentials: credentials for authentication :param client_type: 'tempest' or 'official' :param interface: 'json' or 'xml'. Applicable for tempest client only """ credentials = self._convert_credentials(credentials) if self.check_credentials(credentials): self.credentials = credentials else: raise TypeError("Invalid credentials") self.client_type = client_type self.interface = interface if self.client_type == 'tempest' and self.interface is None: self.interface = 'json' self.cache = None self.alt_auth_data = None self.alt_part = None def _convert_credentials(self, credentials): # Support dict credentials for backwards compatibility if isinstance(credentials, dict): return get_credentials(**credentials) else: return credentials def __str__(self): return "Creds :{creds}, client type: {client_type}, interface: " \ "{interface}, cached auth data: {cache}".format( creds=self.credentials, client_type=self.client_type, interface=self.interface, cache=self.cache ) def _decorate_request(self, filters, method, url, headers=None, body=None, auth_data=None): """ Decorate request with authentication data """ raise NotImplementedError def _get_auth(self): raise NotImplementedError def _fill_credentials(self, auth_data_body): raise NotImplementedError def fill_credentials(self): """ Fill credentials object with data from auth """ auth_data = self.get_auth() self._fill_credentials(auth_data[1]) return self.credentials @classmethod def check_credentials(cls, credentials): """ Verify credentials are valid. """ return isinstance(credentials, Credentials) and credentials.is_valid() @property def auth_data(self): return self.get_auth() @auth_data.deleter def auth_data(self): self.clear_auth() def get_auth(self): """ Returns auth from cache if available, else auth first """ if self.cache is None or self.is_expired(self.cache): self.set_auth() return self.cache def set_auth(self): """ Forces setting auth, ignores cache if it exists. Refills credentials """ self.cache = self._get_auth() self._fill_credentials(self.cache[1]) def clear_auth(self): """ Can be called to clear the access cache so that next request will fetch a new token and base_url. """ self.cache = None self.credentials.reset() def is_expired(self, auth_data): raise NotImplementedError def auth_request(self, method, url, headers=None, body=None, filters=None): """ Obtains auth data and decorates a request with that. :param method: HTTP method of the request :param url: relative URL of the request (path) :param headers: HTTP headers of the request :param body: HTTP body in case of POST / PUT :param filters: select a base URL out of the catalog :returns a Tuple (url, headers, body) """ orig_req = dict(url=url, headers=headers, body=body) auth_url, auth_headers, auth_body = self._decorate_request( filters, method, url, headers, body) auth_req = dict(url=auth_url, headers=auth_headers, body=auth_body) # Overwrite part if the request if it has been requested if self.alt_part is not None: if self.alt_auth_data is not None: alt_url, alt_headers, alt_body = self._decorate_request( filters, method, url, headers, body, auth_data=self.alt_auth_data) alt_auth_req = dict(url=alt_url, headers=alt_headers, body=alt_body) auth_req[self.alt_part] = alt_auth_req[self.alt_part] else: # If alt auth data is None, skip auth in the requested part auth_req[self.alt_part] = orig_req[self.alt_part] # Next auth request will be normal, unless otherwise requested self.reset_alt_auth_data() return auth_req['url'], auth_req['headers'], auth_req['body'] def reset_alt_auth_data(self): """ Configure auth provider to provide valid authentication data """ self.alt_part = None self.alt_auth_data = None def set_alt_auth_data(self, request_part, auth_data): """ Configure auth provider to provide alt authentication data on a part of the *next* auth_request. If credentials are None, set invalid data. :param request_part: request part to contain invalid auth: url, headers, body :param auth_data: alternative auth_data from which to get the invalid data to be injected """ self.alt_part = request_part self.alt_auth_data = auth_data def base_url(self, filters, auth_data=None): """ Extracts the base_url based on provided filters """ raise NotImplementedError class KeystoneAuthProvider(AuthProvider): token_expiry_threshold = datetime.timedelta(seconds=60) def __init__(self, credentials, client_type='tempest', interface=None): super(KeystoneAuthProvider, self).__init__(credentials, client_type, interface) self.auth_client = self._auth_client() def _decorate_request(self, filters, method, url, headers=None, body=None, auth_data=None): if auth_data is None: auth_data = self.auth_data token, _ = auth_data base_url = self.base_url(filters=filters, auth_data=auth_data) # build authenticated request # returns new request, it does not touch the original values _headers = copy.deepcopy(headers) if headers is not None else {} _headers['X-Auth-Token'] = token if url is None or url == "": _url = base_url else: # Join base URL and url, and remove multiple contiguous slashes _url = "/".join([base_url, url]) parts = [x for x in urlparse.urlparse(_url)] parts[2] = re.sub("/{2,}", "/", parts[2]) _url = urlparse.urlunparse(parts) # no change to method or body return _url, _headers, body def _auth_client(self): raise NotImplementedError def _auth_params(self): raise NotImplementedError def _get_auth(self): # Bypasses the cache if self.client_type == 'tempest': auth_func = getattr(self.auth_client, 'get_token') auth_params = self._auth_params() # returns token, auth_data token, auth_data = auth_func(**auth_params) return token, auth_data else: raise NotImplementedError def get_token(self): return self.auth_data[0] class KeystoneV2AuthProvider(KeystoneAuthProvider): EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' def _auth_client(self): if self.client_type == 'tempest': if self.interface == 'json': return json_id.TokenClientJSON() else: return xml_id.TokenClientXML() else: raise NotImplementedError def _auth_params(self): if self.client_type == 'tempest': return dict( user=self.credentials.username, password=self.credentials.password, tenant=self.credentials.tenant_name, auth_data=True) else: raise NotImplementedError def _fill_credentials(self, auth_data_body): tenant = auth_data_body['token']['tenant'] user = auth_data_body['user'] if self.credentials.tenant_name is None: self.credentials.tenant_name = tenant['name'] if self.credentials.tenant_id is None: self.credentials.tenant_id = tenant['id'] if self.credentials.username is None: self.credentials.username = user['name'] if self.credentials.user_id is None: self.credentials.user_id = user['id'] def base_url(self, filters, auth_data=None): """ Filters can be: - service: compute, image, etc - region: the service region - endpoint_type: adminURL, publicURL, internalURL - api_version: replace catalog version with this - skip_path: take just the base URL """ if auth_data is None: auth_data = self.auth_data token, _auth_data = auth_data service = filters.get('service') region = filters.get('region') endpoint_type = filters.get('endpoint_type', 'publicURL') if service is None: raise exceptions.EndpointNotFound("No service provided") _base_url = None for ep in _auth_data['serviceCatalog']: if ep["type"] == service: for _ep in ep['endpoints']: if region is not None and _ep['region'] == region: _base_url = _ep.get(endpoint_type) if not _base_url: # No region matching, use the first _base_url = ep['endpoints'][0].get(endpoint_type) break if _base_url is None: raise exceptions.EndpointNotFound(service) parts = urlparse.urlparse(_base_url) if filters.get('api_version', None) is not None: path = "/" + filters['api_version'] noversion_path = "/".join(parts.path.split("/")[2:]) if noversion_path != "": path += "/" + noversion_path _base_url = _base_url.replace(parts.path, path) if filters.get('skip_path', None) is not None: _base_url = _base_url.replace(parts.path, "/") return _base_url def is_expired(self, auth_data): _, access = auth_data expiry = datetime.datetime.strptime(access['token']['expires'], self.EXPIRY_DATE_FORMAT) return expiry - self.token_expiry_threshold <= \ datetime.datetime.utcnow() class KeystoneV3AuthProvider(KeystoneAuthProvider): EXPIRY_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' def _auth_client(self): if self.client_type == 'tempest': if self.interface == 'json': return json_v3id.V3TokenClientJSON() else: return xml_v3id.V3TokenClientXML() else: raise NotImplementedError def _auth_params(self): if self.client_type == 'tempest': return dict( user=self.credentials.username, password=self.credentials.password, tenant=self.credentials.tenant_name, domain=self.credentials.user_domain_name, auth_data=True) else: raise NotImplementedError def _fill_credentials(self, auth_data_body): # project or domain, depending on the scope project = auth_data_body.get('project', None) domain = auth_data_body.get('domain', None) # user is always there user = auth_data_body['user'] # Set project fields if project is not None: if self.credentials.project_name is None: self.credentials.project_name = project['name'] if self.credentials.project_id is None: self.credentials.project_id = project['id'] if self.credentials.project_domain_id is None: self.credentials.project_domain_id = project['domain']['id'] if self.credentials.project_domain_name is None: self.credentials.project_domain_name = \ project['domain']['name'] # Set domain fields if domain is not None: if self.credentials.domain_id is None: self.credentials.domain_id = domain['id'] if self.credentials.domain_name is None: self.credentials.domain_name = domain['name'] # Set user fields if self.credentials.username is None: self.credentials.username = user['name'] if self.credentials.user_id is None: self.credentials.user_id = user['id'] if self.credentials.user_domain_id is None: self.credentials.user_domain_id = user['domain']['id'] if self.credentials.user_domain_name is None: self.credentials.user_domain_name = user['domain']['name'] def base_url(self, filters, auth_data=None): """ Filters can be: - service: compute, image, etc - region: the service region - endpoint_type: adminURL, publicURL, internalURL - api_version: replace catalog version with this - skip_path: take just the base URL """ if auth_data is None: auth_data = self.auth_data token, _auth_data = auth_data service = filters.get('service') region = filters.get('region') endpoint_type = filters.get('endpoint_type', 'public') if service is None: raise exceptions.EndpointNotFound("No service provided") if 'URL' in endpoint_type: endpoint_type = endpoint_type.replace('URL', '') _base_url = None catalog = _auth_data['catalog'] # Select entries with matching service type service_catalog = [ep for ep in catalog if ep['type'] == service] if len(service_catalog) > 0: service_catalog = service_catalog[0]['endpoints'] else: # No matching service raise exceptions.EndpointNotFound(service) # Filter by endpoint type (interface) filtered_catalog = [ep for ep in service_catalog if ep['interface'] == endpoint_type] if len(filtered_catalog) == 0: # No matching type, keep all and try matching by region at least filtered_catalog = service_catalog # Filter by region filtered_catalog = [ep for ep in filtered_catalog if ep['region'] == region] if len(filtered_catalog) == 0: # No matching region, take the first endpoint filtered_catalog = [service_catalog[0]] # There should be only one match. If not take the first. _base_url = filtered_catalog[0].get('url', None) if _base_url is None: raise exceptions.EndpointNotFound(service) parts = urlparse.urlparse(_base_url) if filters.get('api_version', None) is not None: path = "/" + filters['api_version'] noversion_path = "/".join(parts.path.split("/")[2:]) if noversion_path != "": path += "/" + noversion_path _base_url = _base_url.replace(parts.path, path) if filters.get('skip_path', None) is not None: _base_url = _base_url.replace(parts.path, "/") return _base_url def is_expired(self, auth_data): _, access = auth_data expiry = datetime.datetime.strptime(access['expires_at'], self.EXPIRY_DATE_FORMAT) return expiry - self.token_expiry_threshold <= \ datetime.datetime.utcnow() def get_default_credentials(credential_type, fill_in=True): """ Returns configured credentials of the specified type based on the configured auth_version """ return get_credentials(fill_in=fill_in, credential_type=credential_type) def get_credentials(credential_type=None, fill_in=True, **kwargs): """ Builds a credentials object based on the configured auth_version :param credential_type (string): requests credentials from tempest configuration file. Valid values are defined in Credentials.TYPE. :param kwargs (dict): take into account only if credential_type is not specified or None. Dict of credential key/value pairs Examples: Returns credentials from the provided parameters: >>> get_credentials(username='foo', password='bar') Returns credentials from tempest configuration: >>> get_credentials(credential_type='user') """ if CONF.identity.auth_version == 'v2': credential_class = KeystoneV2Credentials auth_provider_class = KeystoneV2AuthProvider elif CONF.identity.auth_version == 'v3': credential_class = KeystoneV3Credentials auth_provider_class = KeystoneV3AuthProvider else: raise exceptions.InvalidConfiguration('Unsupported auth version') if credential_type is not None: creds = credential_class.get_default(credential_type) else: creds = credential_class(**kwargs) # Fill in the credentials fields that were not specified if fill_in: auth_provider = auth_provider_class(creds) creds = auth_provider.fill_credentials() return creds class Credentials(object): """ Set of credentials for accessing OpenStack services ATTRIBUTES: list of valid class attributes representing credentials. TYPES: types of credentials available in the configuration file. For each key there's a tuple (section, prefix) to match the configuration options. """ ATTRIBUTES = [] TYPES = { 'identity_admin': ('identity', 'admin'), 'compute_admin': ('compute_admin', None), 'user': ('identity', None), 'alt_user': ('identity', 'alt') } def __init__(self, **kwargs): """ Enforce the available attributes at init time (only). Additional attributes can still be set afterwards if tests need to do so. """ self._initial = kwargs self._apply_credentials(kwargs) def _apply_credentials(self, attr): for key in attr.keys(): if key in self.ATTRIBUTES: setattr(self, key, attr[key]) else: raise exceptions.InvalidCredentials def __str__(self): """ Represent only attributes included in self.ATTRIBUTES """ _repr = dict((k, getattr(self, k)) for k in self.ATTRIBUTES) return str(_repr) def __eq__(self, other): """ Credentials are equal if attributes in self.ATTRIBUTES are equal """ return str(self) == str(other) def __getattr__(self, key): # If an attribute is set, __getattr__ is not invoked # If an attribute is not set, and it is a known one, return None if key in self.ATTRIBUTES: return None else: raise AttributeError def __delitem__(self, key): # For backwards compatibility, support dict behaviour if key in self.ATTRIBUTES: delattr(self, key) else: raise AttributeError def get(self, item, default): # In this patch act as dict for backward compatibility try: return getattr(self, item) except AttributeError: return default @classmethod def get_default(cls, credentials_type): if credentials_type not in cls.TYPES: raise exceptions.InvalidCredentials() creds = cls._get_default(credentials_type) if not creds.is_valid(): raise exceptions.InvalidConfiguration() return creds @classmethod def _get_default(cls, credentials_type): raise NotImplementedError def is_valid(self): raise NotImplementedError def reset(self): # First delete all known attributes for key in self.ATTRIBUTES: if getattr(self, key) is not None: delattr(self, key) # Then re-apply initial setup self._apply_credentials(self._initial) class KeystoneV2Credentials(Credentials): CONF_ATTRIBUTES = ['username', 'password', 'tenant_name'] ATTRIBUTES = ['user_id', 'tenant_id'] ATTRIBUTES.extend(CONF_ATTRIBUTES) @classmethod def _get_default(cls, credentials_type='user'): params = {} section, prefix = cls.TYPES[credentials_type] for attr in cls.CONF_ATTRIBUTES: _section = getattr(CONF, section) if prefix is None: params[attr] = getattr(_section, attr) else: params[attr] = getattr(_section, prefix + "_" + attr) return cls(**params) def is_valid(self): """ Minimum set of valid credentials, are username and password. Tenant is optional. """ return None not in (self.username, self.password) class KeystoneV3Credentials(KeystoneV2Credentials): """ Credentials suitable for the Keystone Identity V3 API """ CONF_ATTRIBUTES = ['domain_name', 'password', 'tenant_name', 'username'] ATTRIBUTES = ['project_domain_id', 'project_domain_name', 'project_id', 'project_name', 'tenant_id', 'tenant_name', 'user_domain_id', 'user_domain_name', 'user_id'] ATTRIBUTES.extend(CONF_ATTRIBUTES) def __init__(self, **kwargs): """ If domain is not specified, load the one configured for the identity manager. """ domain_fields = set(x for x in self.ATTRIBUTES if 'domain' in x) if not domain_fields.intersection(kwargs.keys()): kwargs['user_domain_name'] = CONF.identity.admin_domain_name super(KeystoneV3Credentials, self).__init__(**kwargs) def __setattr__(self, key, value): parent = super(KeystoneV3Credentials, self) # for tenant_* set both project and tenant if key == 'tenant_id': parent.__setattr__('project_id', value) elif key == 'tenant_name': parent.__setattr__('project_name', value) # for project_* set both project and tenant if key == 'project_id': parent.__setattr__('tenant_id', value) elif key == 'project_name': parent.__setattr__('tenant_name', value) # for *_domain_* set both user and project if not set yet if key == 'user_domain_id': if self.project_domain_id is None: parent.__setattr__('project_domain_id', value) if key == 'project_domain_id': if self.user_domain_id is None: parent.__setattr__('user_domain_id', value) if key == 'user_domain_name': if self.project_domain_name is None: parent.__setattr__('project_domain_name', value) if key == 'project_domain_name': if self.user_domain_name is None: parent.__setattr__('user_domain_name', value) # support domain_name coming from config if key == 'domain_name': parent.__setattr__('user_domain_name', value) parent.__setattr__('project_domain_name', value) # finally trigger default behaviour for all attributes parent.__setattr__(key, value) def is_valid(self): """ Valid combinations of v3 credentials (excluding token, scope) - User id, password (optional domain) - User name, password and its domain id/name For the scope, valid combinations are: - None - Project id (optional domain) - Project name and its domain id/name """ valid_user_domain = any( [self.user_domain_id is not None, self.user_domain_name is not None]) valid_project_domain = any( [self.project_domain_id is not None, self.project_domain_name is not None]) valid_user = any( [self.user_id is not None, self.username is not None and valid_user_domain]) valid_project = any( [self.project_name is None and self.project_id is None, self.project_id is not None, self.project_name is not None and valid_project_domain]) return all([self.password is not None, valid_user, valid_project])