| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You 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. |
| |
| """ |
| Common / shared code for handling authentication against OpenStack identity |
| service (Keystone). |
| """ |
| |
| import datetime |
| from collections import namedtuple |
| |
| from libcloud.utils.py3 import httplib |
| from libcloud.common.base import Response, ConnectionUserAndKey, CertificateConnection |
| from libcloud.compute.types import LibcloudError, InvalidCredsError, MalformedResponseError |
| from libcloud.utils.iso8601 import parse_date |
| |
| try: |
| import simplejson as json |
| except ImportError: |
| import json # type: ignore |
| |
| AUTH_API_VERSION = "1.1" |
| AUTH_TOKEN_HEADER = "X-Auth-Token" |
| |
| # Auth versions which contain token expiration information. |
| AUTH_VERSIONS_WITH_EXPIRES = [ |
| "1.1", |
| "2.0", |
| "2.0_apikey", |
| "2.0_password", |
| "2.0_voms", |
| "3.0", |
| "3.x_password", |
| "3.x_appcred", |
| "3.x_oidc_access_token", |
| ] |
| |
| # How many seconds to subtract from the auth token expiration time before |
| # testing if the token is still valid. |
| # The time is subtracted to account for the HTTP request latency and prevent |
| # user from getting "InvalidCredsError" if token is about to expire. |
| AUTH_TOKEN_EXPIRES_GRACE_SECONDS = 5 |
| |
| |
| __all__ = [ |
| "OpenStackAuthenticationCache", |
| "OpenStackAuthenticationCacheKey", |
| "OpenStackAuthenticationContext", |
| "OpenStackIdentityVersion", |
| "OpenStackIdentityDomain", |
| "OpenStackIdentityProject", |
| "OpenStackIdentityUser", |
| "OpenStackIdentityRole", |
| "OpenStackServiceCatalog", |
| "OpenStackServiceCatalogEntry", |
| "OpenStackServiceCatalogEntryEndpoint", |
| "OpenStackIdentityEndpointType", |
| "OpenStackIdentityConnection", |
| "OpenStackIdentity_1_0_Connection", |
| "OpenStackIdentity_1_1_Connection", |
| "OpenStackIdentity_2_0_Connection", |
| "OpenStackIdentity_2_0_Connection_VOMS", |
| "OpenStackIdentity_3_0_Connection", |
| "OpenStackIdentity_3_0_Connection_AppCred", |
| "OpenStackIdentity_3_0_Connection_OIDC_access_token", |
| "get_class_for_auth_version", |
| ] |
| |
| |
| class OpenStackAuthenticationCache: |
| """ |
| Base class for external OpenStack authentication caches. |
| |
| Authentication tokens are always cached in memory in |
| :class:`OpenStackIdentityConnection`.auth_token and related fields. These |
| tokens are lost when the driver is garbage collected. To share tokens |
| among multiple drivers, processes, or systems, use an |
| :class:`OpenStackAuthenticationCache` in |
| OpenStackIdentityConnection.auth_cache. |
| |
| Cache implementors should inherit this class and define the methods below. |
| """ |
| |
| def get(self, key): |
| """ |
| Get an authentication context from the cache. |
| |
| :param key: Key to fetch. |
| :type key: :class:`.OpenStackAuthenticationCacheKey` |
| |
| :return: The cached context for the given key, if present; None if not. |
| :rtype: :class:`OpenStackAuthenticationContext` |
| """ |
| raise NotImplementedError |
| |
| def put(self, key, context): |
| """ |
| Put an authentication context into the cache. |
| |
| :param key: Key where the context will be stored. |
| :type key: :class:`.OpenStackAuthenticationCacheKey` |
| |
| :param context: The context to cache. |
| :type context: :class:`.OpenStackAuthenticationContext` |
| """ |
| raise NotImplementedError |
| |
| def clear(self, key): |
| """ |
| Clear an authentication context from the cache. |
| |
| :param key: Key to clear. |
| :type key: :class:`.OpenStackAuthenticationCacheKey` |
| """ |
| raise NotImplementedError |
| |
| |
| OpenStackAuthenticationCacheKey = namedtuple( |
| "OpenStackAuthenticationCacheKey", |
| [ |
| "auth_url", |
| "user_id", |
| "token_scope", |
| "tenant_name", |
| "domain_name", |
| "tenant_domain_id", |
| ], |
| ) |
| |
| |
| class OpenStackAuthenticationContext: |
| """ |
| An authentication token and related context. |
| """ |
| |
| def __init__(self, token, expiration=None, user=None, roles=None, urls=None): |
| self.token = token |
| self.expiration = expiration |
| self.user = user |
| self.roles = roles |
| self.urls = urls |
| |
| |
| class OpenStackIdentityEndpointType: |
| """ |
| Enum class for openstack identity endpoint type. |
| """ |
| |
| INTERNAL = "internal" |
| EXTERNAL = "external" |
| ADMIN = "admin" |
| |
| |
| class OpenStackIdentityTokenScope: |
| """ |
| Enum class for openstack identity token scope. |
| """ |
| |
| PROJECT = "project" |
| DOMAIN = "domain" |
| UNSCOPED = "unscoped" |
| |
| |
| class OpenStackIdentityVersion: |
| def __init__(self, version, status, updated, url): |
| self.version = version |
| self.status = status |
| self.updated = updated |
| self.url = url |
| |
| def __repr__(self): |
| return "<OpenStackIdentityVersion version=%s, status=%s, " "updated=%s, url=%s>" % ( |
| self.version, |
| self.status, |
| self.updated, |
| self.url, |
| ) |
| |
| |
| class OpenStackIdentityDomain: |
| def __init__(self, id, name, enabled): |
| self.id = id |
| self.name = name |
| self.enabled = enabled |
| |
| def __repr__(self): |
| return "<OpenStackIdentityDomain id={}, name={}, enabled={}>".format( |
| self.id, |
| self.name, |
| self.enabled, |
| ) |
| |
| |
| class OpenStackIdentityProject: |
| def __init__(self, id, name, description, enabled, domain_id=None): |
| self.id = id |
| self.name = name |
| self.description = description |
| self.enabled = enabled |
| self.domain_id = domain_id |
| |
| def __repr__(self): |
| return "<OpenStackIdentityProject id=%s, domain_id=%s, name=%s, " "enabled=%s>" % ( |
| self.id, |
| self.domain_id, |
| self.name, |
| self.enabled, |
| ) |
| |
| |
| class OpenStackIdentityRole: |
| def __init__(self, id, name, description, enabled): |
| self.id = id |
| self.name = name |
| self.description = description |
| self.enabled = enabled |
| |
| def __repr__(self): |
| return "<OpenStackIdentityRole id=%s, name=%s, description=%s, " "enabled=%s>" % ( |
| self.id, |
| self.name, |
| self.description, |
| self.enabled, |
| ) |
| |
| |
| class OpenStackIdentityUser: |
| def __init__(self, id, domain_id, name, email, description, enabled): |
| self.id = id |
| self.domain_id = domain_id |
| self.name = name |
| self.email = email |
| self.description = description |
| self.enabled = enabled |
| |
| def __repr__(self): |
| return "<OpenStackIdentityUser id=%s, domain_id=%s, name=%s, " "email=%s, enabled=%s>" % ( |
| self.id, |
| self.domain_id, |
| self.name, |
| self.email, |
| self.enabled, |
| ) |
| |
| |
| class OpenStackServiceCatalog: |
| """ |
| http://docs.openstack.org/api/openstack-identity-service/2.0/content/ |
| |
| This class should be instantiated with the contents of the |
| 'serviceCatalog' in the auth response. This will do the work of figuring |
| out which services actually exist in the catalog as well as split them up |
| by type, name, and region if available |
| """ |
| |
| _auth_version = None |
| _service_catalog = None |
| |
| def __init__(self, service_catalog, auth_version=AUTH_API_VERSION): |
| self._auth_version = auth_version |
| |
| # Check this way because there are a couple of different 2.0_* |
| # auth types. |
| if "3.x" in self._auth_version: |
| entries = self._parse_service_catalog_auth_v3(service_catalog=service_catalog) |
| elif "2.0" in self._auth_version: |
| entries = self._parse_service_catalog_auth_v2(service_catalog=service_catalog) |
| elif ("1.1" in self._auth_version) or ("1.0" in self._auth_version): |
| entries = self._parse_service_catalog_auth_v1(service_catalog=service_catalog) |
| else: |
| raise LibcloudError('auth version "%s" not supported' % (self._auth_version)) |
| |
| # Force consistent ordering by sorting the entries |
| entries = sorted(entries, key=lambda x: x.service_type + (x.service_name or "")) |
| self._entries = entries # stories all the service catalog entries |
| |
| def get_entries(self): |
| """ |
| Return all the entries for this service catalog. |
| |
| :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntry` |
| """ |
| return self._entries |
| |
| def get_catalog(self): |
| """ |
| Deprecated in the favor of ``get_entries`` method. |
| """ |
| return self.get_entries() |
| |
| def get_public_urls(self, service_type=None, name=None): |
| """ |
| Retrieve all the available public (external) URLs for the provided |
| service type and name. |
| """ |
| endpoints = self.get_endpoints(service_type=service_type, name=name) |
| |
| result = [] |
| for endpoint in endpoints: |
| endpoint_type = endpoint.endpoint_type |
| if endpoint_type == OpenStackIdentityEndpointType.EXTERNAL: |
| result.append(endpoint.url) |
| |
| return result |
| |
| def get_endpoints(self, service_type=None, name=None): |
| """ |
| Retrieve all the endpoints for the provided service type and name. |
| |
| :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntryEndpoint` |
| """ |
| endpoints = [] |
| |
| for entry in self._entries: |
| # Note: "if XXX and YYY != XXX" comparison is used to support |
| # partial lookups. |
| # This allows user to pass in only one argument to the method (only |
| # service_type or name), both of them or neither. |
| if service_type and entry.service_type != service_type: |
| continue |
| |
| if name and entry.service_name != name: |
| continue |
| |
| for endpoint in entry.endpoints: |
| endpoints.append(endpoint) |
| |
| return endpoints |
| |
| def get_endpoint( |
| self, |
| service_type=None, |
| name=None, |
| region=None, |
| endpoint_type=OpenStackIdentityEndpointType.EXTERNAL, |
| ): |
| """ |
| Retrieve a single endpoint using the provided criteria. |
| |
| Note: If no or more than one matching endpoint is found, an exception |
| is thrown. |
| """ |
| endpoints = [] |
| |
| for entry in self._entries: |
| if service_type and entry.service_type != service_type: |
| continue |
| |
| if name and entry.service_name != name: |
| continue |
| |
| for endpoint in entry.endpoints: |
| if region and endpoint.region != region: |
| continue |
| |
| if endpoint_type and endpoint.endpoint_type != endpoint_type: |
| continue |
| |
| endpoints.append(endpoint) |
| |
| if len(endpoints) == 1: |
| return endpoints[0] |
| elif len(endpoints) > 1: |
| raise ValueError("Found more than 1 matching endpoint") |
| else: |
| raise LibcloudError("Could not find specified endpoint") |
| |
| def get_regions(self, service_type=None): |
| """ |
| Retrieve a list of all the available regions. |
| |
| :param service_type: If specified, only return regions for this |
| service type. |
| :type service_type: ``str`` |
| |
| :rtype: ``list`` of ``str`` |
| """ |
| regions = set() |
| |
| for entry in self._entries: |
| if service_type and entry.service_type != service_type: |
| continue |
| |
| for endpoint in entry.endpoints: |
| if endpoint.region: |
| regions.add(endpoint.region) |
| |
| return sorted(list(regions)) |
| |
| def get_service_types(self, region=None): |
| """ |
| Retrieve all the available service types. |
| |
| :param region: Optional region to retrieve service types for. |
| :type region: ``str`` |
| |
| :rtype: ``list`` of ``str`` |
| """ |
| service_types = set() |
| |
| for entry in self._entries: |
| include = True |
| |
| for endpoint in entry.endpoints: |
| if region and endpoint.region != region: |
| include = False |
| break |
| |
| if include: |
| service_types.add(entry.service_type) |
| |
| return sorted(list(service_types)) |
| |
| def get_service_names(self, service_type=None, region=None): |
| """ |
| Retrieve list of service names that match service type and region. |
| |
| :type service_type: ``str`` |
| :type region: ``str`` |
| |
| :rtype: ``list`` of ``str`` |
| """ |
| names = set() |
| |
| if "2.0" not in self._auth_version: |
| raise ValueError("Unsupported version: %s" % (self._auth_version)) |
| |
| for entry in self._entries: |
| if service_type and entry.service_type != service_type: |
| continue |
| |
| include = True |
| for endpoint in entry.endpoints: |
| if region and endpoint.region != region: |
| include = False |
| break |
| |
| if include and entry.service_name: |
| names.add(entry.service_name) |
| |
| return sorted(list(names)) |
| |
| def _parse_service_catalog_auth_v1(self, service_catalog): |
| entries = [] |
| |
| for service, endpoints in service_catalog.items(): |
| entry_endpoints = [] |
| for endpoint in endpoints: |
| region = endpoint.get("region", None) |
| |
| public_url = endpoint.get("publicURL", None) |
| private_url = endpoint.get("internalURL", None) |
| |
| if public_url: |
| entry_endpoint = OpenStackServiceCatalogEntryEndpoint( |
| region=region, |
| url=public_url, |
| endpoint_type=OpenStackIdentityEndpointType.EXTERNAL, |
| ) |
| entry_endpoints.append(entry_endpoint) |
| |
| if private_url: |
| entry_endpoint = OpenStackServiceCatalogEntryEndpoint( |
| region=region, |
| url=private_url, |
| endpoint_type=OpenStackIdentityEndpointType.INTERNAL, |
| ) |
| entry_endpoints.append(entry_endpoint) |
| |
| entry = OpenStackServiceCatalogEntry(service_type=service, endpoints=entry_endpoints) |
| entries.append(entry) |
| |
| return entries |
| |
| def _parse_service_catalog_auth_v2(self, service_catalog): |
| entries = [] |
| |
| for service in service_catalog: |
| service_type = service["type"] |
| service_name = service.get("name", None) |
| |
| entry_endpoints = [] |
| for endpoint in service.get("endpoints", []): |
| region = endpoint.get("region", None) |
| |
| public_url = endpoint.get("publicURL", None) |
| private_url = endpoint.get("internalURL", None) |
| |
| if public_url: |
| entry_endpoint = OpenStackServiceCatalogEntryEndpoint( |
| region=region, |
| url=public_url, |
| endpoint_type=OpenStackIdentityEndpointType.EXTERNAL, |
| ) |
| entry_endpoints.append(entry_endpoint) |
| |
| if private_url: |
| entry_endpoint = OpenStackServiceCatalogEntryEndpoint( |
| region=region, |
| url=private_url, |
| endpoint_type=OpenStackIdentityEndpointType.INTERNAL, |
| ) |
| entry_endpoints.append(entry_endpoint) |
| |
| entry = OpenStackServiceCatalogEntry( |
| service_type=service_type, |
| endpoints=entry_endpoints, |
| service_name=service_name, |
| ) |
| entries.append(entry) |
| |
| return entries |
| |
| def _parse_service_catalog_auth_v3(self, service_catalog): |
| entries = [] |
| |
| for item in service_catalog: |
| service_type = item["type"] |
| service_name = item.get("name", None) |
| |
| entry_endpoints = [] |
| for endpoint in item["endpoints"]: |
| region = endpoint.get("region", None) |
| url = endpoint["url"] |
| endpoint_type = endpoint["interface"] |
| |
| if endpoint_type == "internal": |
| endpoint_type = OpenStackIdentityEndpointType.INTERNAL |
| elif endpoint_type == "public": |
| endpoint_type = OpenStackIdentityEndpointType.EXTERNAL |
| elif endpoint_type == "admin": |
| endpoint_type = OpenStackIdentityEndpointType.ADMIN |
| |
| entry_endpoint = OpenStackServiceCatalogEntryEndpoint( |
| region=region, url=url, endpoint_type=endpoint_type |
| ) |
| entry_endpoints.append(entry_endpoint) |
| |
| entry = OpenStackServiceCatalogEntry( |
| service_type=service_type, |
| service_name=service_name, |
| endpoints=entry_endpoints, |
| ) |
| entries.append(entry) |
| |
| return entries |
| |
| |
| class OpenStackServiceCatalogEntry: |
| def __init__(self, service_type, endpoints=None, service_name=None): |
| """ |
| :param service_type: Service type. |
| :type service_type: ``str`` |
| |
| :param endpoints: Endpoints belonging to this entry. |
| :type endpoints: ``list`` |
| |
| :param service_name: Optional service name. |
| :type service_name: ``str`` |
| """ |
| self.service_type = service_type |
| self.endpoints = endpoints or [] |
| self.service_name = service_name |
| |
| # For consistency, sort the endpoints |
| self.endpoints = sorted(self.endpoints, key=lambda x: x.url or "") |
| |
| def __eq__(self, other): |
| return ( |
| self.service_type == other.service_type |
| and self.endpoints == other.endpoints |
| and other.service_name == self.service_name |
| ) |
| |
| def __ne__(self, other): |
| return not self.__eq__(other=other) |
| |
| def __repr__(self): |
| return "<OpenStackServiceCatalogEntry service_type=%s, " "service_name=%s, endpoints=%s" % ( |
| self.service_type, |
| self.service_name, |
| repr(self.endpoints), |
| ) |
| |
| |
| class OpenStackServiceCatalogEntryEndpoint: |
| VALID_ENDPOINT_TYPES = [ |
| OpenStackIdentityEndpointType.INTERNAL, |
| OpenStackIdentityEndpointType.EXTERNAL, |
| OpenStackIdentityEndpointType.ADMIN, |
| ] |
| |
| def __init__(self, region, url, endpoint_type="external"): |
| """ |
| :param region: Endpoint region. |
| :type region: ``str`` |
| |
| :param url: Endpoint URL. |
| :type url: ``str`` |
| |
| :param endpoint_type: Endpoint type (external / internal / admin). |
| :type endpoint_type: ``str`` |
| """ |
| if endpoint_type not in self.VALID_ENDPOINT_TYPES: |
| raise ValueError("Invalid type: %s" % (endpoint_type)) |
| |
| # TODO: Normalize / lowercase all the region names |
| self.region = region |
| self.url = url |
| self.endpoint_type = endpoint_type |
| |
| def __eq__(self, other): |
| return ( |
| self.region == other.region |
| and self.url == other.url |
| and self.endpoint_type == other.endpoint_type |
| ) |
| |
| def __ne__(self, other): |
| return not self.__eq__(other=other) |
| |
| def __repr__(self): |
| return "<OpenStackServiceCatalogEntryEndpoint region=%s, url=%s, " "type=%s" % ( |
| self.region, |
| self.url, |
| self.endpoint_type, |
| ) |
| |
| |
| class OpenStackAuthResponse(Response): |
| def success(self): |
| return self.status in [ |
| httplib.OK, |
| httplib.CREATED, |
| httplib.ACCEPTED, |
| httplib.NO_CONTENT, |
| httplib.MULTIPLE_CHOICES, |
| httplib.UNAUTHORIZED, |
| httplib.INTERNAL_SERVER_ERROR, |
| ] |
| |
| def parse_body(self): |
| if not self.body: |
| return None |
| |
| if "content-type" in self.headers: |
| key = "content-type" |
| elif "Content-Type" in self.headers: |
| key = "Content-Type" |
| else: |
| raise LibcloudError("Missing content-type header", driver=OpenStackIdentityConnection) |
| |
| content_type = self.headers[key] |
| if content_type.find(";") != -1: |
| content_type = content_type.split(";")[0] |
| |
| if content_type == "application/json": |
| try: |
| data = json.loads(self.body) |
| except Exception: |
| driver = OpenStackIdentityConnection |
| raise MalformedResponseError("Failed to parse JSON", body=self.body, driver=driver) |
| elif content_type == "text/plain": |
| data = self.body |
| else: |
| data = self.body |
| |
| return data |
| |
| |
| class OpenStackIdentityConnection(ConnectionUserAndKey): |
| """ |
| Base identity connection class which contains common / shared logic. |
| |
| Note: This class shouldn't be instantiated directly. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| timeout = None |
| auth_version = None # type: str |
| |
| def __init__( |
| self, |
| auth_url, |
| user_id, |
| key, |
| tenant_name=None, |
| tenant_domain_id="default", |
| domain_name="Default", |
| token_scope=OpenStackIdentityTokenScope.PROJECT, |
| timeout=None, |
| proxy_url=None, |
| parent_conn=None, |
| auth_cache=None, |
| ): |
| super().__init__( |
| user_id=user_id, key=key, url=auth_url, timeout=timeout, proxy_url=proxy_url |
| ) |
| |
| self.parent_conn = parent_conn |
| |
| # enable tests to use the same mock connection classes. |
| if parent_conn: |
| self.conn_class = parent_conn.conn_class |
| self.driver = parent_conn.driver |
| else: |
| self.driver = None |
| |
| self.auth_url = auth_url |
| self.tenant_name = tenant_name |
| self.domain_name = domain_name |
| self.tenant_domain_id = tenant_domain_id |
| self.token_scope = token_scope |
| self.timeout = timeout |
| self.auth_cache = auth_cache |
| |
| self.urls = {} |
| self.auth_token = None |
| self.auth_token_expires = None |
| self.auth_user_info = None |
| self.auth_user_roles = None |
| |
| def authenticated_request( |
| self, action, params=None, data=None, headers=None, method="GET", raw=False |
| ): |
| """ |
| Perform an authenticated request against the identity API. |
| """ |
| if not self.auth_token: |
| raise ValueError("Need to be authenticated to perform this request") |
| |
| headers = headers or {} |
| headers[AUTH_TOKEN_HEADER] = self.auth_token |
| |
| response = self.request( |
| action=action, |
| params=params, |
| data=data, |
| headers=headers, |
| method=method, |
| raw=raw, |
| ) |
| # Evict cached auth token if we receive Unauthorized while using it |
| if response.status == httplib.UNAUTHORIZED: |
| self.clear_cached_auth_context() |
| return response |
| |
| def morph_action_hook(self, action): |
| (_, _, _, request_path) = self._tuple_from_url(self.auth_url) |
| |
| if request_path == "": |
| # No path is provided in the auth_url, use action passed to this |
| # method. |
| return action |
| |
| return super().morph_action_hook(action=action) |
| |
| def add_default_headers(self, headers): |
| headers["Accept"] = "application/json" |
| headers["Content-Type"] = "application/json; charset=UTF-8" |
| return headers |
| |
| def is_token_valid(self): |
| """ |
| Return True if the current auth token is already cached and hasn't |
| expired yet. |
| |
| :return: ``True`` if the token is still valid, ``False`` otherwise. |
| :rtype: ``bool`` |
| """ |
| if not self.auth_token: |
| return False |
| |
| if not self.auth_token_expires: |
| return False |
| |
| expires = self.auth_token_expires - datetime.timedelta( |
| seconds=AUTH_TOKEN_EXPIRES_GRACE_SECONDS |
| ) |
| |
| time_tuple_expires = expires.utctimetuple() |
| time_tuple_now = datetime.datetime.utcnow().utctimetuple() |
| |
| if time_tuple_now < time_tuple_expires: |
| return True |
| |
| return False |
| |
| def authenticate(self, force=False): |
| """ |
| Authenticate against the identity API. |
| |
| :param force: Forcefully update the token even if it's already cached |
| and still valid. |
| :type force: ``bool`` |
| """ |
| raise NotImplementedError("authenticate not implemented") |
| |
| def clear_cached_auth_context(self): |
| """ |
| Clear the cached authentication context. |
| |
| The context is cleared from fields on this connection and from the |
| external cache, if one is configured. |
| """ |
| self.auth_token = None |
| self.auth_token_expires = None |
| self.auth_user_info = None |
| self.auth_user_roles = None |
| self.urls = {} |
| |
| if self.auth_cache is not None: |
| self.auth_cache.clear(self._cache_key) |
| |
| def list_supported_versions(self): |
| """ |
| Retrieve a list of all the identity versions which are supported by |
| this installation. |
| |
| :rtype: ``list`` of :class:`.OpenStackIdentityVersion` |
| """ |
| response = self.request("/", method="GET") |
| result = self._to_versions(data=response.object["versions"]["values"]) |
| result = sorted(result, key=lambda x: x.version) |
| return result |
| |
| def _to_versions(self, data): |
| result = [] |
| for item in data: |
| version = self._to_version(data=item) |
| result.append(version) |
| |
| return result |
| |
| def _to_version(self, data): |
| try: |
| updated = parse_date(data["updated"]) |
| except Exception: |
| updated = None |
| |
| try: |
| url = data["links"][0]["href"] |
| except IndexError: |
| url = None |
| |
| version = OpenStackIdentityVersion( |
| version=data["id"], status=data["status"], updated=updated, url=url |
| ) |
| return version |
| |
| def _is_authentication_needed(self, force=False): |
| """ |
| Determine if the authentication is needed or if the existing token (if |
| any exists) is still valid. |
| """ |
| if force: |
| return True |
| |
| if self.auth_version not in AUTH_VERSIONS_WITH_EXPIRES: |
| return True |
| |
| if self.is_token_valid(): |
| return False |
| |
| # See if there's a new token in the cache |
| self._load_auth_context_from_cache() |
| |
| # If there was a token in the cache, it is now stored in our local |
| # auth_token and related fields. Ensure it is still valid. |
| if self.is_token_valid(): |
| return False |
| |
| return True |
| |
| def _to_projects(self, data): |
| result = [] |
| for item in data: |
| project = self._to_project(data=item) |
| result.append(project) |
| |
| return result |
| |
| def _to_project(self, data): |
| project = OpenStackIdentityProject( |
| id=data["id"], |
| name=data["name"], |
| description=data["description"], |
| enabled=data["enabled"], |
| domain_id=data.get("domain_id", None), |
| ) |
| return project |
| |
| @property |
| def _cache_key(self): |
| """ |
| The key where this connection's authentication context will be cached. |
| |
| :rtype: :class:`OpenStackAuthenticationCacheKey` |
| """ |
| return OpenStackAuthenticationCacheKey( |
| self.auth_url, |
| self.user_id, |
| self.token_scope, |
| self.tenant_name, |
| self.domain_name, |
| self.tenant_domain_id, |
| ) |
| |
| def _cache_auth_context(self, context): |
| """ |
| Store an authentication context in memory and the cache. |
| |
| :param context: Authentication context to cache. |
| :type key: :class:`.OpenStackAuthenticationContext` |
| """ |
| self.urls = context.urls |
| self.auth_token = context.token |
| self.auth_token_expires = context.expiration |
| self.auth_user_info = context.user |
| self.auth_user_roles = context.roles |
| |
| if self.auth_cache is not None: |
| self.auth_cache.put(self._cache_key, context) |
| |
| def _load_auth_context_from_cache(self): |
| """ |
| Fetch an authentication context for this connection from the cache. |
| |
| :rtype: :class:`OpenStackAuthenticationContext` |
| """ |
| if self.auth_cache is None: |
| return None |
| |
| context = self.auth_cache.get(self._cache_key) |
| if context is None: |
| return None |
| |
| self.urls = context.urls |
| self.auth_token = context.token |
| self.auth_token_expires = context.expiration |
| self.auth_user_info = context.user |
| self.auth_user_roles = context.roles |
| return context |
| |
| |
| class OpenStackIdentity_1_0_Connection(OpenStackIdentityConnection): |
| """ |
| Connection class for Keystone API v1.0. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v1.0" |
| auth_version = "1.0" |
| |
| def authenticate(self, force=False): |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| headers = { |
| "X-Auth-User": self.user_id, |
| "X-Auth-Key": self.key, |
| } |
| |
| resp = self.request("/v1.0", headers=headers, method="GET") |
| |
| if resp.status == httplib.UNAUTHORIZED: |
| # HTTP UNAUTHORIZED (401): auth failed |
| raise InvalidCredsError() |
| elif resp.status not in [httplib.NO_CONTENT, httplib.OK]: |
| body = "code: {} body:{} headers:{}".format( |
| resp.status, |
| resp.body, |
| resp.headers, |
| ) |
| raise MalformedResponseError("Malformed response", body=body, driver=self.driver) |
| else: |
| headers = resp.headers |
| # emulate the auth 1.1 URL list |
| self.urls = {} |
| self.urls["cloudServers"] = [ |
| {"publicURL": headers.get("x-server-management-url", None)} |
| ] |
| self.urls["cloudFilesCDN"] = [{"publicURL": headers.get("x-cdn-management-url", None)}] |
| self.urls["cloudFiles"] = [{"publicURL": headers.get("x-storage-url", None)}] |
| self.auth_token = headers.get("x-auth-token", None) |
| self.auth_user_info = None |
| |
| if not self.auth_token: |
| raise MalformedResponseError("Missing X-Auth-Token in" " response headers") |
| |
| return self |
| |
| |
| class OpenStackIdentity_1_1_Connection(OpenStackIdentityConnection): |
| """ |
| Connection class for Keystone API v1.1. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v1.1" |
| auth_version = "1.1" |
| |
| def authenticate(self, force=False): |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| reqbody = json.dumps({"credentials": {"username": self.user_id, "key": self.key}}) |
| resp = self.request("/v1.1/auth", data=reqbody, headers={}, method="POST") |
| |
| if resp.status == httplib.UNAUTHORIZED: |
| # HTTP UNAUTHORIZED (401): auth failed |
| raise InvalidCredsError() |
| elif resp.status != httplib.OK: |
| body = "code: {} body:{}".format(resp.status, resp.body) |
| raise MalformedResponseError("Malformed response", body=body, driver=self.driver) |
| else: |
| try: |
| body = json.loads(resp.body) |
| except Exception as e: |
| raise MalformedResponseError("Failed to parse JSON", e) |
| |
| try: |
| expires = body["auth"]["token"]["expires"] |
| self._cache_auth_context( |
| OpenStackAuthenticationContext( |
| body["auth"]["token"]["id"], |
| expiration=parse_date(expires), |
| urls=body["auth"]["serviceCatalog"], |
| ) |
| ) |
| except KeyError as e: |
| raise MalformedResponseError( |
| "Auth JSON response is \ |
| missing required elements", |
| e, |
| ) |
| |
| return self |
| |
| |
| class OpenStackIdentity_2_0_Connection(OpenStackIdentityConnection): |
| """ |
| Connection class for Keystone API v2.0. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v1.0" |
| auth_version = "2.0" |
| |
| def authenticate(self, auth_type="api_key", force=False): |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| if auth_type == "api_key": |
| return self._authenticate_2_0_with_api_key() |
| elif auth_type == "password": |
| return self._authenticate_2_0_with_password() |
| else: |
| raise ValueError("Invalid value for auth_type argument") |
| |
| def _authenticate_2_0_with_api_key(self): |
| # API Key based authentication uses the RAX-KSKEY extension. |
| # http://s.apache.org/oAi |
| data = { |
| "auth": { |
| "RAX-KSKEY:apiKeyCredentials": { |
| "username": self.user_id, |
| "apiKey": self.key, |
| } |
| } |
| } |
| if self.tenant_name: |
| data["auth"]["tenantName"] = self.tenant_name |
| reqbody = json.dumps(data) |
| return self._authenticate_2_0_with_body(reqbody) |
| |
| def _authenticate_2_0_with_password(self): |
| # Password based authentication is the only 'core' authentication |
| # method in Keystone at this time. |
| # 'keystone' - http://s.apache.org/e8h |
| data = {"auth": {"passwordCredentials": {"username": self.user_id, "password": self.key}}} |
| if self.tenant_name: |
| data["auth"]["tenantName"] = self.tenant_name |
| reqbody = json.dumps(data) |
| return self._authenticate_2_0_with_body(reqbody) |
| |
| def _authenticate_2_0_with_body(self, reqbody): |
| resp = self.request( |
| "/v2.0/tokens", |
| data=reqbody, |
| headers={"Content-Type": "application/json"}, |
| method="POST", |
| ) |
| |
| if resp.status == httplib.UNAUTHORIZED: |
| raise InvalidCredsError() |
| elif resp.status not in [httplib.OK, httplib.NON_AUTHORITATIVE_INFORMATION]: |
| body = "code: {} body: {}".format(resp.status, resp.body) |
| raise MalformedResponseError("Malformed response", body=body, driver=self.driver) |
| else: |
| body = resp.object |
| |
| try: |
| access = body["access"] |
| expires = access["token"]["expires"] |
| self._cache_auth_context( |
| OpenStackAuthenticationContext( |
| access["token"]["id"], |
| expiration=parse_date(expires), |
| urls=access["serviceCatalog"], |
| user=access.get("user", {}), |
| ) |
| ) |
| except KeyError as e: |
| raise MalformedResponseError( |
| "Auth JSON response is \ |
| missing required elements", |
| e, |
| ) |
| |
| return self |
| |
| def list_projects(self): |
| response = self.authenticated_request("/v2.0/tenants", method="GET") |
| result = self._to_projects(data=response.object["tenants"]) |
| return result |
| |
| def list_tenants(self): |
| return self.list_projects() |
| |
| |
| class OpenStackIdentity_3_0_Connection(OpenStackIdentityConnection): |
| """ |
| Connection class for Keystone API v3.x. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v3.x" |
| auth_version = "3.0" |
| |
| VALID_TOKEN_SCOPES = [ |
| OpenStackIdentityTokenScope.PROJECT, |
| OpenStackIdentityTokenScope.DOMAIN, |
| OpenStackIdentityTokenScope.UNSCOPED, |
| ] |
| |
| def __init__( |
| self, |
| auth_url, |
| user_id, |
| key, |
| tenant_name=None, |
| domain_name="Default", |
| tenant_domain_id="default", |
| token_scope=OpenStackIdentityTokenScope.PROJECT, |
| timeout=None, |
| proxy_url=None, |
| parent_conn=None, |
| auth_cache=None, |
| ): |
| """ |
| :param tenant_name: Name of the project this user belongs to. Note: |
| When token_scope is set to project, this argument |
| control to which project to scope the token to. |
| :type tenant_name: ``str`` |
| |
| :param domain_name: Domain the user belongs to. Note: When token_scope |
| is set to token, this argument controls to which |
| domain to scope the token to. |
| :type domain_name: ``str`` |
| |
| :param token_scope: Whether to scope a token to a "project", a |
| "domain" or "unscoped" |
| :type token_scope: ``str`` |
| |
| :param auth_cache: Where to cache authentication tokens. |
| :type auth_cache: :class:`OpenStackAuthenticationCache` |
| """ |
| super().__init__( |
| auth_url=auth_url, |
| user_id=user_id, |
| key=key, |
| tenant_name=tenant_name, |
| domain_name=domain_name, |
| tenant_domain_id=tenant_domain_id, |
| token_scope=token_scope, |
| timeout=timeout, |
| proxy_url=proxy_url, |
| parent_conn=parent_conn, |
| auth_cache=auth_cache, |
| ) |
| |
| if self.token_scope not in self.VALID_TOKEN_SCOPES: |
| raise ValueError('Invalid value for "token_scope" argument: %s' % (self.token_scope)) |
| |
| if self.token_scope == OpenStackIdentityTokenScope.PROJECT and ( |
| not self.tenant_name or not self.domain_name |
| ): |
| raise ValueError("Must provide tenant_name and domain_name " "argument") |
| elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN and not self.domain_name: |
| raise ValueError("Must provide domain_name argument") |
| |
| def authenticate(self, force=False): |
| """ |
| Perform authentication. |
| """ |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| data = self._get_auth_data() |
| data = json.dumps(data) |
| response = self.request( |
| "/v3/auth/tokens", |
| data=data, |
| headers={"Content-Type": "application/json"}, |
| method="POST", |
| ) |
| self._parse_token_response(response, cache_it=True) |
| return self |
| |
| def list_domains(self): |
| """ |
| List the available domains. |
| |
| :rtype: ``list`` of :class:`OpenStackIdentityDomain` |
| """ |
| response = self.authenticated_request("/v3/domains", method="GET") |
| result = self._to_domains(data=response.object["domains"]) |
| return result |
| |
| def list_projects(self): |
| """ |
| List the available projects. |
| |
| Note: To perform this action, user you are currently authenticated with |
| needs to be an admin. |
| |
| :rtype: ``list`` of :class:`OpenStackIdentityProject` |
| """ |
| response = self.authenticated_request("/v3/projects", method="GET") |
| result = self._to_projects(data=response.object["projects"]) |
| return result |
| |
| def list_users(self): |
| """ |
| List the available users. |
| |
| :rtype: ``list`` of :class:`.OpenStackIdentityUser` |
| """ |
| response = self.authenticated_request("/v3/users", method="GET") |
| result = self._to_users(data=response.object["users"]) |
| return result |
| |
| def list_roles(self): |
| """ |
| List the available roles. |
| |
| :rtype: ``list`` of :class:`.OpenStackIdentityRole` |
| """ |
| response = self.authenticated_request("/v3/roles", method="GET") |
| result = self._to_roles(data=response.object["roles"]) |
| return result |
| |
| def get_domain(self, domain_id): |
| """ |
| Retrieve information about a single domain. |
| |
| :param domain_id: ID of domain to retrieve information for. |
| :type domain_id: ``str`` |
| |
| :rtype: :class:`.OpenStackIdentityDomain` |
| """ |
| response = self.authenticated_request("/v3/domains/%s" % (domain_id), method="GET") |
| result = self._to_domain(data=response.object["domain"]) |
| return result |
| |
| def get_user(self, user_id): |
| """ |
| Get a user account by ID. |
| |
| :param user_id: User's id. |
| :type name: ``str`` |
| |
| :return: Located user. |
| :rtype: :class:`.OpenStackIdentityUser` |
| """ |
| response = self.authenticated_request("/v3/users/%s" % user_id) |
| user = self._to_user(data=response.object["user"]) |
| return user |
| |
| def list_user_projects(self, user): |
| """ |
| Retrieve all the projects user belongs to. |
| |
| :rtype: ``list`` of :class:`.OpenStackIdentityProject` |
| """ |
| path = "/v3/users/%s/projects" % (user.id) |
| response = self.authenticated_request(path, method="GET") |
| result = self._to_projects(data=response.object["projects"]) |
| return result |
| |
| def list_user_domain_roles(self, domain, user): |
| """ |
| Retrieve all the roles for a particular user on a domain. |
| |
| :rtype: ``list`` of :class:`.OpenStackIdentityRole` |
| """ |
| # TODO: Also add "get users roles" and "get assginements" which are |
| # available in 3.1 and 3.3 |
| path = "/v3/domains/{}/users/{}/roles".format(domain.id, user.id) |
| response = self.authenticated_request(path, method="GET") |
| result = self._to_roles(data=response.object["roles"]) |
| return result |
| |
| def grant_domain_role_to_user(self, domain, role, user): |
| """ |
| Grant domain role to a user. |
| |
| Note: This function appears to be idempotent. |
| |
| :param domain: Domain to grant the role to. |
| :type domain: :class:`.OpenStackIdentityDomain` |
| |
| :param role: Role to grant. |
| :type role: :class:`.OpenStackIdentityRole` |
| |
| :param user: User to grant the role to. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: ``True`` on success. |
| :rtype: ``bool`` |
| """ |
| path = "/v3/domains/{}/users/{}/roles/{}".format(domain.id, user.id, role.id) |
| response = self.authenticated_request(path, method="PUT") |
| return response.status == httplib.NO_CONTENT |
| |
| def revoke_domain_role_from_user(self, domain, user, role): |
| """ |
| Revoke domain role from a user. |
| |
| :param domain: Domain to revoke the role from. |
| :type domain: :class:`.OpenStackIdentityDomain` |
| |
| :param role: Role to revoke. |
| :type role: :class:`.OpenStackIdentityRole` |
| |
| :param user: User to revoke the role from. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: ``True`` on success. |
| :rtype: ``bool`` |
| """ |
| path = "/v3/domains/{}/users/{}/roles/{}".format(domain.id, user.id, role.id) |
| response = self.authenticated_request(path, method="DELETE") |
| return response.status == httplib.NO_CONTENT |
| |
| def grant_project_role_to_user(self, project, role, user): |
| """ |
| Grant project role to a user. |
| |
| Note: This function appears to be idempotent. |
| |
| :param project: Project to grant the role to. |
| :type project: :class:`.OpenStackIdentityDomain` |
| |
| :param role: Role to grant. |
| :type role: :class:`.OpenStackIdentityRole` |
| |
| :param user: User to grant the role to. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: ``True`` on success. |
| :rtype: ``bool`` |
| """ |
| path = "/v3/projects/{}/users/{}/roles/{}".format(project.id, user.id, role.id) |
| response = self.authenticated_request(path, method="PUT") |
| return response.status == httplib.NO_CONTENT |
| |
| def revoke_project_role_from_user(self, project, role, user): |
| """ |
| Revoke project role from a user. |
| |
| :param project: Project to revoke the role from. |
| :type project: :class:`.OpenStackIdentityDomain` |
| |
| :param role: Role to revoke. |
| :type role: :class:`.OpenStackIdentityRole` |
| |
| :param user: User to revoke the role from. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: ``True`` on success. |
| :rtype: ``bool`` |
| """ |
| path = "/v3/projects/{}/users/{}/roles/{}".format(project.id, user.id, role.id) |
| response = self.authenticated_request(path, method="DELETE") |
| return response.status == httplib.NO_CONTENT |
| |
| def create_user( |
| self, |
| email, |
| password, |
| name, |
| description=None, |
| domain_id=None, |
| default_project_id=None, |
| enabled=True, |
| ): |
| """ |
| Create a new user account. |
| |
| :param email: User's mail address. |
| :type email: ``str`` |
| |
| :param password: User's password. |
| :type password: ``str`` |
| |
| :param name: User's name. |
| :type name: ``str`` |
| |
| :param description: Optional description. |
| :type description: ``str`` |
| |
| :param domain_id: ID of the domain to add the user to (optional). |
| :type domain_id: ``str`` |
| |
| :param default_project_id: ID of the default user project (optional). |
| :type default_project_id: ``str`` |
| |
| :param enabled: True to enable user after creation. |
| :type enabled: ``bool`` |
| |
| :return: Created user. |
| :rtype: :class:`.OpenStackIdentityUser` |
| """ |
| data = {"email": email, "password": password, "name": name, "enabled": enabled} |
| |
| if description: |
| data["description"] = description |
| |
| if domain_id: |
| data["domain_id"] = domain_id |
| |
| if default_project_id: |
| data["default_project_id"] = default_project_id |
| |
| data = json.dumps({"user": data}) |
| response = self.authenticated_request("/v3/users", data=data, method="POST") |
| |
| user = self._to_user(data=response.object["user"]) |
| return user |
| |
| def enable_user(self, user): |
| """ |
| Enable user account. |
| |
| Note: This operation appears to be idempotent. |
| |
| :param user: User to enable. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: User account which has been enabled. |
| :rtype: :class:`.OpenStackIdentityUser` |
| """ |
| data = {"enabled": True} |
| data = json.dumps({"user": data}) |
| response = self.authenticated_request("/v3/users/%s" % (user.id), data=data, method="PATCH") |
| |
| user = self._to_user(data=response.object["user"]) |
| return user |
| |
| def disable_user(self, user): |
| """ |
| Disable user account. |
| |
| Note: This operation appears to be idempotent. |
| |
| :param user: User to disable. |
| :type user: :class:`.OpenStackIdentityUser` |
| |
| :return: User account which has been disabled. |
| :rtype: :class:`.OpenStackIdentityUser` |
| """ |
| data = {"enabled": False} |
| data = json.dumps({"user": data}) |
| response = self.authenticated_request("/v3/users/%s" % (user.id), data=data, method="PATCH") |
| |
| user = self._to_user(data=response.object["user"]) |
| return user |
| |
| def _get_auth_data(self): |
| data = { |
| "auth": { |
| "identity": { |
| "methods": ["password"], |
| "password": { |
| "user": { |
| "domain": {"name": self.domain_name}, |
| "name": self.user_id, |
| "password": self.key, |
| } |
| }, |
| } |
| } |
| } |
| |
| if self.token_scope == OpenStackIdentityTokenScope.PROJECT: |
| # Scope token to project (tenant) |
| data["auth"]["scope"] = { |
| "project": { |
| "domain": {"id": self.tenant_domain_id}, |
| "name": self.tenant_name, |
| } |
| } |
| elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN: |
| # Scope token to domain |
| data["auth"]["scope"] = {"domain": {"name": self.domain_name}} |
| elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED: |
| pass |
| else: |
| raise ValueError("Token needs to be scoped either to project or " "a domain") |
| |
| return data |
| |
| def _load_auth_context_from_cache(self): |
| context = super()._load_auth_context_from_cache() |
| if context is None: |
| return None |
| |
| # Since v3 only caches the token and expiration, fetch the |
| # service catalog and other bits of the authentication context |
| # from Keystone. |
| try: |
| self._fetch_auth_token() |
| except InvalidCredsError: |
| # Unauthorized; cached auth context was cleared as part of |
| # _fetch_auth_token |
| return None |
| |
| # Local auth context variables set in _fetch_auth_token |
| return context |
| |
| def _parse_token_response(self, response, cache_it=False, raise_ambiguous_version_error=True): |
| """ |
| Parse a response from /v3/auth/tokens. |
| |
| :param cache_it: Should we cache the authentication context? |
| :type cache_it: ``bool`` |
| |
| :param raise_ambiguous_version_error: Should an ambiguous version |
| error be raised on a 300 response? |
| :type raise_ambiguous_version_error: ``bool`` |
| """ |
| if response.status == httplib.UNAUTHORIZED: |
| raise InvalidCredsError() |
| elif response.status in [httplib.OK, httplib.CREATED]: |
| headers = response.headers |
| |
| try: |
| body = json.loads(response.body) |
| except Exception as e: |
| raise MalformedResponseError("Failed to parse JSON", e) |
| |
| try: |
| roles = self._to_roles(body["token"]["roles"]) |
| except Exception: |
| roles = [] |
| |
| try: |
| expires = parse_date(body["token"]["expires_at"]) |
| token = headers["x-subject-token"] |
| |
| # Cache the fewest fields required for token reuse to minimize |
| # cache size. Other fields, especially the service catalog, can |
| # be quite large. Fetch these from Keystone when the token is |
| # first loaded from cache. |
| if cache_it: |
| self._cache_auth_context( |
| OpenStackAuthenticationContext(token, expiration=expires) |
| ) |
| |
| self.auth_token = token |
| self.auth_token_expires = expires |
| # Note: catalog is not returned for unscoped tokens |
| self.urls = body["token"].get("catalog", None) |
| self.auth_user_info = body["token"].get("user", None) |
| self.auth_user_roles = roles |
| except KeyError as e: |
| raise MalformedResponseError( |
| "Auth JSON response is \ |
| missing required elements", |
| e, |
| ) |
| elif raise_ambiguous_version_error and response.status == 300: |
| # ambiguous version request |
| raise LibcloudError( |
| "Auth request returned ambiguous version error, try" |
| "using the version specific URL to connect," |
| " e.g. identity/v3/auth/tokens" |
| ) |
| else: |
| body = "code: {} body:{}".format(response.status, response.body) |
| raise MalformedResponseError("Malformed response", body=body, driver=self.driver) |
| |
| def _fetch_auth_token(self): |
| """ |
| Fetch our authentication token and service catalog. |
| """ |
| headers = {"X-Subject-Token": self.auth_token} |
| response = self.authenticated_request("/v3/auth/tokens", headers=headers) |
| self._parse_token_response(response) |
| return self |
| |
| def _to_domains(self, data): |
| result = [] |
| for item in data: |
| domain = self._to_domain(data=item) |
| result.append(domain) |
| |
| return result |
| |
| def _to_domain(self, data): |
| domain = OpenStackIdentityDomain(id=data["id"], name=data["name"], enabled=data["enabled"]) |
| return domain |
| |
| def _to_users(self, data): |
| result = [] |
| for item in data: |
| user = self._to_user(data=item) |
| result.append(user) |
| |
| return result |
| |
| def _to_user(self, data): |
| user = OpenStackIdentityUser( |
| id=data["id"], |
| domain_id=data["domain_id"], |
| name=data["name"], |
| email=data.get("email"), |
| description=data.get("description", None), |
| enabled=data.get("enabled"), |
| ) |
| return user |
| |
| def _to_roles(self, data): |
| result = [] |
| for item in data: |
| user = self._to_role(data=item) |
| result.append(user) |
| |
| return result |
| |
| def _to_role(self, data): |
| role = OpenStackIdentityRole( |
| id=data["id"], |
| name=data["name"], |
| description=data.get("description", None), |
| enabled=data.get("enabled", True), |
| ) |
| return role |
| |
| |
| class OpenStackIdentity_3_0_Connection_AppCred(OpenStackIdentity_3_0_Connection): |
| """ |
| Connection class for Keystone API v3.x using Application Credentials. |
| |
| 'user_id' is the application credential id and 'key' is the application |
| credential secret. |
| """ |
| |
| name = "OpenStack Identity API v3.x with Application Credentials" |
| |
| def __init__( |
| self, |
| auth_url, |
| user_id, |
| key, |
| tenant_name=None, |
| domain_name=None, |
| tenant_domain_id=None, |
| token_scope=None, |
| timeout=None, |
| proxy_url=None, |
| parent_conn=None, |
| auth_cache=None, |
| ): |
| """ |
| Tenant, domain and scope options are ignored as they are contained |
| within the app credential itself and can't be changed. |
| """ |
| super().__init__( |
| auth_url=auth_url, |
| user_id=user_id, |
| key=key, |
| tenant_name=tenant_name, |
| domain_name=domain_name, |
| token_scope=OpenStackIdentityTokenScope.UNSCOPED, |
| timeout=timeout, |
| proxy_url=proxy_url, |
| parent_conn=parent_conn, |
| auth_cache=auth_cache, |
| ) |
| |
| def _get_auth_data(self): |
| data = { |
| "auth": { |
| "identity": { |
| "methods": ["application_credential"], |
| "application_credential": {"id": self.user_id, "secret": self.key}, |
| } |
| } |
| } |
| return data |
| |
| |
| class OpenStackIdentity_3_0_Connection_OIDC_access_token(OpenStackIdentity_3_0_Connection): |
| """ |
| Connection class for Keystone API v3.x. using OpenID Connect tokens |
| |
| The OIDC token must be set in the self.key attribute. |
| |
| The identity provider name required to get the full path |
| must be set in the self.user_id attribute. |
| |
| The protocol name required to get the full path |
| must be set in the self.tenant_name attribute. |
| |
| The self.domain_name attribute can be used either to select the |
| domain name in case of domain scoped token or to select the project |
| name in case of project scoped token |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v3.x with OIDC support" |
| auth_version = "3.0" |
| |
| def authenticate(self, force=False): |
| """ |
| Perform authentication. |
| """ |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| subject_token = self._get_unscoped_token_from_oidc_token() |
| |
| data = {"auth": {"identity": {"methods": ["token"], "token": {"id": subject_token}}}} |
| |
| if self.token_scope == OpenStackIdentityTokenScope.PROJECT: |
| # Scope token to project (tenant) |
| project_id = self._get_project_id(token=subject_token) |
| data["auth"]["scope"] = {"project": {"id": project_id}} |
| elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN: |
| # Scope token to domain |
| data["auth"]["scope"] = {"domain": {"name": self.domain_name}} |
| elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED: |
| pass |
| else: |
| raise ValueError("Token needs to be scoped either to project or " "a domain") |
| |
| data = json.dumps(data) |
| response = self.request( |
| "/v3/auth/tokens", |
| data=data, |
| headers={"Content-Type": "application/json"}, |
| method="POST", |
| ) |
| self._parse_token_response(response, cache_it=True, raise_ambiguous_version_error=False) |
| return self |
| |
| def _get_unscoped_token_from_oidc_token(self): |
| """ |
| Get unscoped token from OIDC access token |
| """ |
| path = "/v3/OS-FEDERATION/identity_providers/{}/protocols/{}/auth".format( |
| self.user_id, |
| self.tenant_name, |
| ) |
| response = self.request( |
| path, |
| headers={ |
| "Content-Type": "application/json", |
| "Authorization": "Bearer %s" % self.key, |
| }, |
| method="GET", |
| ) |
| |
| if response.status == httplib.UNAUTHORIZED: |
| # Invalid credentials |
| raise InvalidCredsError() |
| elif response.status in [httplib.OK, httplib.CREATED]: |
| if "x-subject-token" in response.headers: |
| return response.headers["x-subject-token"] |
| else: |
| raise MalformedResponseError("No x-subject-token returned", driver=self.driver) |
| else: |
| raise MalformedResponseError( |
| "Malformed response", driver=self.driver, body=response.body |
| ) |
| |
| def _get_project_id(self, token): |
| """ |
| Get the first project ID accessible with the specified access token |
| """ |
| # Try new path first (from ver 1.1) |
| path = "/v3/auth/projects" |
| response = self.request( |
| path, |
| headers={"Content-Type": "application/json", AUTH_TOKEN_HEADER: token}, |
| method="GET", |
| ) |
| |
| if response.status not in [httplib.UNAUTHORIZED, httplib.OK, httplib.CREATED]: |
| # In case of error try old one |
| path = "/v3/OS-FEDERATION/projects" |
| response = self.request( |
| path, |
| headers={"Content-Type": "application/json", AUTH_TOKEN_HEADER: token}, |
| method="GET", |
| ) |
| |
| if response.status == httplib.UNAUTHORIZED: |
| # Invalid credentials |
| raise InvalidCredsError() |
| elif response.status in [httplib.OK, httplib.CREATED]: |
| try: |
| body = json.loads(response.body) |
| # We use domain_name in both cases of the scoped tokens |
| # as we have used tenant as the protocol |
| if self.domain_name and self.domain_name != "Default": |
| for project in body["projects"]: |
| if self.domain_name in [project["name"], project["id"]]: |
| return project["id"] |
| raise ValueError("Project %s not found" % (self.domain_name)) |
| else: |
| return body["projects"][0]["id"] |
| except ValueError as e: |
| raise e |
| except Exception as e: |
| raise MalformedResponseError("Failed to parse JSON", e) |
| else: |
| raise MalformedResponseError( |
| "Malformed response", driver=self.driver, body=response.body |
| ) |
| |
| |
| class OpenStackIdentity_2_0_Connection_VOMS(OpenStackIdentityConnection, CertificateConnection): |
| """ |
| Connection class for Keystone API v2.0. with VOMS proxy support |
| In this case the key parameter will be the path of the VOMS proxy file. |
| """ |
| |
| responseCls = OpenStackAuthResponse |
| name = "OpenStack Identity API v2.0 VOMS support" |
| auth_version = "2.0" |
| |
| def __init__( |
| self, |
| auth_url, |
| user_id, |
| key, |
| tenant_name=None, |
| tenant_domain_id="default", |
| domain_name="Default", |
| token_scope=OpenStackIdentityTokenScope.PROJECT, |
| timeout=None, |
| proxy_url=None, |
| parent_conn=None, |
| auth_cache=None, |
| ): |
| CertificateConnection.__init__( |
| self, cert_file=key, url=auth_url, proxy_url=proxy_url, timeout=timeout |
| ) |
| |
| self.parent_conn = parent_conn |
| |
| # enable tests to use the same mock connection classes. |
| if parent_conn: |
| self.conn_class = parent_conn.conn_class |
| self.driver = parent_conn.driver |
| else: |
| self.driver = None |
| |
| self.auth_url = auth_url |
| self.tenant_name = tenant_name |
| self.tenant_domain_id = tenant_domain_id |
| self.domain_name = domain_name |
| self.token_scope = token_scope |
| self.timeout = timeout |
| self.proxy_url = proxy_url |
| self.auth_cache = auth_cache |
| |
| self.urls = {} |
| self.auth_token = None |
| self.auth_token_expires = None |
| self.auth_user_info = None |
| |
| def authenticate(self, force=False): |
| if not self._is_authentication_needed(force=force): |
| return self |
| |
| tenant = self.tenant_name |
| if not tenant: |
| # if the tenant name is not specified look for it |
| token = self._get_unscoped_token() |
| tenant = self._get_tenant_name(token) |
| |
| data = {"auth": {"voms": True, "tenantName": tenant}} |
| |
| reqbody = json.dumps(data) |
| return self._authenticate_2_0_with_body(reqbody) |
| |
| def _get_unscoped_token(self): |
| """ |
| Get unscoped token from VOMS proxy |
| """ |
| data = {"auth": {"voms": True}} |
| reqbody = json.dumps(data) |
| |
| response = self.request( |
| "/v2.0/tokens", |
| data=reqbody, |
| headers={"Content-Type": "application/json"}, |
| method="POST", |
| ) |
| |
| if response.status == httplib.UNAUTHORIZED: |
| # Invalid credentials |
| raise InvalidCredsError() |
| elif response.status in [httplib.OK, httplib.CREATED]: |
| try: |
| body = json.loads(response.body) |
| return body["access"]["token"]["id"] |
| except Exception as e: |
| raise MalformedResponseError("Failed to parse JSON", e) |
| else: |
| raise MalformedResponseError( |
| "Malformed response", driver=self.driver, body=response.body |
| ) |
| |
| def _get_tenant_name(self, token): |
| """ |
| Get the first available tenant name (usually there are only one) |
| """ |
| headers = { |
| "Accept": "application/json", |
| "Content-Type": "application/json", |
| AUTH_TOKEN_HEADER: token, |
| } |
| response = self.request("/v2.0/tenants", headers=headers, method="GET") |
| |
| if response.status == httplib.UNAUTHORIZED: |
| # Invalid credentials |
| raise InvalidCredsError() |
| elif response.status in [httplib.OK, httplib.CREATED]: |
| try: |
| body = json.loads(response.body) |
| return body["tenants"][0]["name"] |
| except Exception as e: |
| raise MalformedResponseError("Failed to parse JSON", e) |
| else: |
| raise MalformedResponseError( |
| "Malformed response", driver=self.driver, body=response.body |
| ) |
| |
| def _authenticate_2_0_with_body(self, reqbody): |
| resp = self.request( |
| "/v2.0/tokens", |
| data=reqbody, |
| headers={"Content-Type": "application/json"}, |
| method="POST", |
| ) |
| |
| if resp.status == httplib.UNAUTHORIZED: |
| raise InvalidCredsError() |
| elif resp.status not in [httplib.OK, httplib.NON_AUTHORITATIVE_INFORMATION]: |
| body = "code: {} body: {}".format(resp.status, resp.body) |
| raise MalformedResponseError("Malformed response", body=body, driver=self.driver) |
| else: |
| body = resp.object |
| |
| try: |
| access = body["access"] |
| expires = access["token"]["expires"] |
| self._cache_auth_context( |
| OpenStackAuthenticationContext( |
| access["token"]["id"], |
| expiration=parse_date(expires), |
| urls=access["serviceCatalog"], |
| user=access.get("user", {}), |
| ) |
| ) |
| except KeyError as e: |
| raise MalformedResponseError( |
| "Auth JSON response is \ |
| missing required elements", |
| e, |
| ) |
| return self |
| |
| |
| def get_class_for_auth_version(auth_version): |
| """ |
| Retrieve class for the provided auth version. |
| """ |
| if auth_version == "1.0": |
| cls = OpenStackIdentity_1_0_Connection |
| elif auth_version == "1.1": |
| cls = OpenStackIdentity_1_1_Connection |
| elif auth_version == "2.0" or auth_version == "2.0_apikey": |
| cls = OpenStackIdentity_2_0_Connection |
| elif auth_version == "2.0_password": |
| cls = OpenStackIdentity_2_0_Connection |
| elif auth_version == "2.0_voms": |
| cls = OpenStackIdentity_2_0_Connection_VOMS |
| elif auth_version == "3.x_password": |
| cls = OpenStackIdentity_3_0_Connection |
| elif auth_version == "3.x_appcred": |
| cls = OpenStackIdentity_3_0_Connection_AppCred |
| elif auth_version == "3.x_oidc_access_token": |
| cls = OpenStackIdentity_3_0_Connection_OIDC_access_token |
| else: |
| raise LibcloudError("Unsupported Auth Version requested: %s" % (auth_version)) |
| |
| return cls |