diff --git a/.stestr.blacklist.functional b/.stestr.blacklist.functional index 310dd5cf6..099986f18 100644 --- a/.stestr.blacklist.functional +++ b/.stestr.blacklist.functional @@ -1,6 +1,8 @@ otcextensions.tests.functional.osclient.obs* otcextensions.tests.functional.osclient.rds.v3.test_instance* otcextensions.tests.functional.osclient.volume_backup* +otcextensions.tests.functional.osclient.nat* +otcextensions.tests.functional.osclient.rds* otcextensions.tests.functional.sdk.cce.v1* otcextensions.tests.functional.sdk.kms* otcextensions.tests.functional.sdk.volume_backup* diff --git a/doc/source/install/configuration.rst b/doc/source/install/configuration.rst index a6a167a27..5b396ea9c 100644 --- a/doc/source/install/configuration.rst +++ b/doc/source/install/configuration.rst @@ -27,7 +27,7 @@ A sample clouds.yaml file is listed below to connect with Open Telekom Cloud: otc: profile: otc auth: - username: '' + username: '' password: '' project_name: '' # or project_id: '<123456_PROJECT_ID>' @@ -73,7 +73,7 @@ to the file as shown below: otcfirstproject: profile: otc auth: - username: '' + username: '' password: '' project_name: '' # or project_id: '<123456_PROJECT_ID>' @@ -114,7 +114,7 @@ secret which is left out from ``clouds.yaml``: otc: profile: otc auth: - username: '' + username: '' project_name: '' # or project_id: '<123456_PROJECT_ID>' user_domain_name: 'OTC00000000001000000xxx' @@ -136,6 +136,33 @@ secret which is left out from ``clouds.yaml``: .. _environment-variables: +Agency based authorization +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Open Telekom Cloud supports a concept of agencies. One domain delegates access +to resources to another domain. After trust relationship is established the +following configuration can be used in ``clouds.yaml``: + +.. code-block:: yaml + + clouds: + otc: + profile: otc + auth_type: agency + auth: + username: '' + project_name: '' + # or project_id: '<123456_PROJECT_ID>' + user_domain_name: 'OTC00000000001000000xxx' + # or user_domain_id: '<123456_DOMAIN_ID>' + auth_url: 'https://iam.eu-de.otc.t-systems.com:443/v3' + target_domain_id: '<123456_DOMAIN_ID>' # Domain where agency is created + # or target_domain_name: '<123456_DOMAIN_NAME' + target_agency_name: 'test_agency' # name of the agency + target_project_name: '<123456_PROJECT_NAME>' # project scoped operations + # or target_project_id: '<123456_PROJECT_ID>' + # When target_project_xx is not set - domain scope is selected + Configuration of Environment Variables -------------------------------------- diff --git a/doc/source/sdk/proxies/identity_v3.rst b/doc/source/sdk/proxies/identity_v3.rst index e26c0b05b..fcf91053b 100644 --- a/doc/source/sdk/proxies/identity_v3.rst +++ b/doc/source/sdk/proxies/identity_v3.rst @@ -10,6 +10,18 @@ The identity high-level interface is available through the ``identity`` member of a :class:`~openstack.connection.Connection` object. The ``identity`` member will only be added if the service is detected. +Agency Operations +^^^^^^^^^^^^^^^^^ + +.. autoclass:: otcextensions.sdk.identity.v3._proxy.Proxy + :noindex: + :members: create_agency, update_agency, delete_agency, + get_agency, find_agency, agencies, + agency_project_roles, check_agency_project_role, + grant_agency_project_role, revoke_agency_project_role, + agency_domain_roles, check_agency_domain_role, + grant_agency_domain_role, revoke_agency_domain_role, + Credential Operations ^^^^^^^^^^^^^^^^^^^^^ diff --git a/doc/source/sdk/resources/identity/index.rst b/doc/source/sdk/resources/identity/index.rst index ffbf172c5..76f1b8aa7 100644 --- a/doc/source/sdk/resources/identity/index.rst +++ b/doc/source/sdk/resources/identity/index.rst @@ -4,6 +4,8 @@ Identity v3 Resources .. toctree:: :maxdepth: 1 + v3/agency + v3/agency_role v3/credential v3/domain v3/endpoint diff --git a/doc/source/sdk/resources/identity/v3/agency.rst b/doc/source/sdk/resources/identity/v3/agency.rst new file mode 100644 index 000000000..33e010fe2 --- /dev/null +++ b/doc/source/sdk/resources/identity/v3/agency.rst @@ -0,0 +1,12 @@ +otcextensions.sdk.identity.v3.agency +==================================== + +.. automodule:: otcextensions.sdk.identity.v3.agency + +The Agency Class +---------------- + +The ``Agency`` class inherits from :class:`~openstack.resource.Resource`. + +.. autoclass:: otcextensions.sdk.identity.v3.agency.Agency + :members: diff --git a/doc/source/sdk/resources/identity/v3/agency_role.rst b/doc/source/sdk/resources/identity/v3/agency_role.rst new file mode 100644 index 000000000..b055f5df7 --- /dev/null +++ b/doc/source/sdk/resources/identity/v3/agency_role.rst @@ -0,0 +1,12 @@ +otcextensions.sdk.identity.v3.agency_role +========================================= + +.. automodule:: otcextensions.sdk.identity.v3.agency_role + +The AgencyRole Class +-------------------- + +The ``AgencyRole`` class inherits from :class:`~openstack.resource.Resource`. + +.. autoclass:: otcextensions.sdk.identity.v3.agency_role.AgencyRole + :members: diff --git a/examples/identity/create_credential.py b/examples/identity/create_credential.py new file mode 100644 index 000000000..3a8ebe590 --- /dev/null +++ b/examples/identity/create_credential.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# 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. +""" +Create AK/SK credentials +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + +credential = conn.identity.create_credential( + description='my creds', + user_id='user_id' +) +print(credential) diff --git a/examples/identity/delete_credential.py b/examples/identity/delete_credential.py new file mode 100644 index 000000000..7267890f7 --- /dev/null +++ b/examples/identity/delete_credential.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +# 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. +""" +Delete AK/SK credentials +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + + +name_or_id = 'AK' +credential = conn.identity.find_credential(name_or_id, ignore_missing=False) +response = conn.identity.delete_credential(credential) diff --git a/examples/identity/find_credential.py b/examples/identity/find_credential.py new file mode 100644 index 000000000..2cd39c71c --- /dev/null +++ b/examples/identity/find_credential.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# 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. +""" +Get AK/SK credentials +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + +credential = 'AK' +credential = conn.identity.find_credential(credential) +print(credential) diff --git a/examples/identity/get_credential.py b/examples/identity/get_credential.py new file mode 100644 index 000000000..d74a4fbb9 --- /dev/null +++ b/examples/identity/get_credential.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# 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. +""" +Get AK/SK credentials +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + +credential = 'AK' +credential = conn.identity.get_credential(credential) +print(credential) diff --git a/examples/identity/list_credentials.py b/examples/identity/list_credentials.py new file mode 100644 index 000000000..5a1b3dfb5 --- /dev/null +++ b/examples/identity/list_credentials.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +# 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. +""" +List all AK/SK credentials +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + +for cred in conn.identity.credentials(): + print(cred) diff --git a/examples/identity/update_credential.py b/examples/identity/update_credential.py new file mode 100644 index 000000000..79a3e9c65 --- /dev/null +++ b/examples/identity/update_credential.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +# 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. +""" +Update AK/SK credentials description or status +""" +import openstack + + +openstack.enable_logging(True) +conn = openstack.connect(cloud='otc') + + +name_or_id = 'AK' +credential = conn.identity.find_credential(name_or_id, ignore_missing=False) +response = conn.identity.update_credential( + credential, + description='my creds', + status='inactive' +) +print(response) diff --git a/otcextensions/common/agency_auth.py b/otcextensions/common/agency_auth.py new file mode 100644 index 000000000..06a62ce88 --- /dev/null +++ b/otcextensions/common/agency_auth.py @@ -0,0 +1,195 @@ +# 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 json + +from keystoneauth1.identity.v3 import base +from keystoneauth1 import access +from keystoneauth1 import exceptions +from keystoneauth1 import loading +from keystoneauth1 import _utils as utils + + +_logger = utils.get_logger(__name__) + + +class AssumeRoleMethod(base.AuthMethod): + _method_parameters = ['user_id', + 'username', + 'user_domain_id', + 'user_domain_name', + 'password', + 'target_agency_name', + 'target_domain_id', + 'target_domain_name', + 'target_project_id', + 'target_project_name'] + + def get_auth_data(self, session, auth, headers, **kwargs): + user = {'password': self.password} + + if self.user_id: + user['id'] = self.user_id + elif self.username: + user['name'] = self.username + + if self.user_domain_id: + user['domain'] = {'id': self.user_domain_id} + elif self.user_domain_name: + user['domain'] = {'name': self.user_domain_name} + + return 'password', {'user': user} + + def get_assume_role_auth_data(self, session, auth, headers, **kwargs): + agency = {'xrole_name': self.target_agency_name} + + if self.target_domain_id: + agency['domain_id'] = self.target_domain_id + elif self.target_domain_name: + agency['domain_name'] = self.target_domain_name + + return 'assume_role', agency + + def get_cache_id_elements(self): + return dict(('assume_role_%s' % p, getattr(self, p)) + for p in self._method_parameters) + + +class Agency(base.AuthConstructor): + """A plugin for authenticating with a username and password. + It then does assume_role + """ + _auth_method_class = AssumeRoleMethod + + def __init__(self, auth_url, + *args, + **kwargs): + super(Agency, self).__init__(auth_url=auth_url, + *args, + **kwargs) + self.target_project_id = kwargs.get('target_project_id') + self.target_project_name = kwargs.get('target_project_name') + self.target_domain_id = kwargs.get('target_domain_id') + self.target_domain_name = kwargs.get('target_domain_name') + + def get_auth_ref(self, session, **kwargs): + # First do regular authorization + auth_access = super(Agency, self).get_auth_ref(session, **kwargs) + # And now reauth with another scope + headers = { + 'Accept': 'application/json', + 'X-Auth-Token': auth_access._auth_token + } + body = {'auth': {'identity': {}}} + ident = body['auth']['identity'] + rkwargs = {} + + for method in self.auth_methods: + name, auth_data = method.get_assume_role_auth_data( + session, self, headers, request_kwargs=rkwargs) + # NOTE(adriant): Methods like ReceiptMethod don't + # want anything added to the request data, so they + # explicitly return None, which we check for. + if name: + ident.setdefault('methods', []).append(name) + ident[name] = auth_data + + if not ident: + raise exceptions.AuthorizationFailure( + 'Authentication method required (e.g. password)') + + if self.target_project_id: + body['auth']['scope'] = {'project': {'id': self.target_project_id}} + elif self.target_project_name: + scope = body['auth']['scope'] = {'project': {}} + scope['project']['name'] = self.target_project_name + # If project is not set - get a domain scope + elif self.target_domain_id: + body['auth']['scope'] = {'domain': + {'id': self.target_domain_id}} + elif self.target_domain_name: + body['auth']['scope'] = {'domain': + {'name': self.target_domain_name}} + + token_url = self.token_url + + if not self.auth_url.rstrip('/').endswith('v3'): + token_url = '%s/v3/auth/tokens' % self.auth_url.rstrip('/') + + if not self.include_catalog: + token_url += '?nocatalog' + + _logger.debug('Making authentication request to %s', token_url) + resp = session.post(token_url, json=body, headers=headers, + authenticated=False, log=False, **rkwargs) + + try: + _logger.debug(json.dumps(resp.json())) + resp_data = resp.json() + except ValueError: + raise exceptions.InvalidResponse(response=resp) + + if 'token' not in resp_data: + raise exceptions.InvalidResponse(response=resp) + + return access.AccessInfoV3(auth_token=resp.headers['X-Subject-Token'], + body=resp_data) + + +class AgencyLoader(loading.BaseV3Loader): + @property + def plugin_class(self): + return Agency + + def get_options(self, **kwargs): + options = super(AgencyLoader, self).get_options() + options.extend([ + loading.Opt('user-id', help='User ID'), + loading.Opt('username', help='Username', + deprecated=[loading.Opt('user-name')]), + loading.Opt('user-domain-id', help="User's domain id"), + loading.Opt('user-domain-name', help="User's domain name"), + loading.Opt('password', secret=True, prompt='Password: ', + help="User's password"), + + loading.Opt('target-agency-name', help="Agency name"), + loading.Opt('target-project-id', + help="Project id available through agency"), + loading.Opt('target-project-name', + help="Project name available through agency"), + loading.Opt('target-domain-id', + help="Domain id available through agency"), + loading.Opt('target-domain-name', + help="Domain name available through agency"), + ]) + return options + + def load_from_options(self, **kwargs): + if ( + kwargs.get('username') + and not (kwargs.get('user_domain_name') + or kwargs.get('user_domain_id')) + ): + m = "You have provided a username. In the V3 identity API a " \ + "username is only unique within a domain so you must " \ + "also provide either a user_domain_id or user_domain_name." + raise exceptions.OptionError(m) + if ( + not kwargs.get('target_agency_name') + or not (kwargs.get('target_domain_id') + or kwargs.get('target_domain_name')) + ): + m = "Using agency based authorization requires " \ + "target_agency_name, target_domain_id or "\ + "target_domain_name at the very minimum" + raise exceptions.OptionError(m) + + return super(AgencyLoader, self).load_from_options(**kwargs) diff --git a/otcextensions/osclient/identity/__init__.py b/otcextensions/osclient/identity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/otcextensions/osclient/identity/client.py b/otcextensions/osclient/identity/client.py new file mode 100644 index 000000000..064f80485 --- /dev/null +++ b/otcextensions/osclient/identity/client.py @@ -0,0 +1,43 @@ +# 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 logging + +from otcextensions import sdk + + +LOG = logging.getLogger(__name__) + +DEFAULT_API_VERSION = '3' +API_VERSION_OPTION = 'os_identity_api_version' +API_NAME = "identity" +API_VERSIONS = { + "3": "openstack.connection.Connection", +} + + +def make_client(instance): + """Returns a identity proxy""" + + conn = instance.sdk_connection + + # register unconditionally, since we need to override default DNS + sdk.register_otc_extensions(conn) + + LOG.debug('identity client initialized using OpenStack OTC SDK: %s', + conn.identity) + return conn.identity + + +def build_option_parser(parser): + """Hook to add global options""" + return parser diff --git a/otcextensions/osclient/identity/v3/__init__.py b/otcextensions/osclient/identity/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/otcextensions/osclient/identity/v3/credential.py b/otcextensions/osclient/identity/v3/credential.py new file mode 100644 index 000000000..6300f72c0 --- /dev/null +++ b/otcextensions/osclient/identity/v3/credential.py @@ -0,0 +1,208 @@ +# 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. +# +'''Identity credential v3 action implementations''' +import logging + +from osc_lib import utils +from osc_lib.command import command + +from otcextensions.i18n import _ +from otcextensions.common import sdk_utils + +LOG = logging.getLogger(__name__) + + +def _get_columns(item): + column_map = { + } + return sdk_utils.get_osc_show_columns_for_sdk_resource(item, column_map) + + +class ListCredentials(command.Lister): + _description = _('List Identity Credentials') + columns = ( + 'access', + 'description', + 'user_id', + 'status', + 'created_at', + ) + + def get_parser(self, prog_name): + parser = super(ListCredentials, self).get_parser(prog_name) + + parser.add_argument( + '--user-id', + metavar='', + help=_('User ID of the user using the credential') + ) + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.identity + + table_columns = ( + 'Access Key', + 'Description', + 'User ID', + 'Status', + 'Created At', + ) + + attrs = {} + + if parsed_args.user_id: + attrs['user_id'] = parsed_args.user_id + + data = client.credentials(**attrs) + + table = (table_columns, + (utils.get_dict_properties( + s, self.columns + ) for s in data)) + return table + + +class ShowCredential(command.ShowOne): + _description = _('Show identity credential details') + + def get_parser(self, prog_name): + parser = super(ShowCredential, self).get_parser(prog_name) + + parser.add_argument( + 'credential', + metavar='', + help=_('Access key of the credential.') + ) + return parser + + def take_action(self, parsed_args): + + client = self.app.client_manager.identity + + obj = client.find_credential( + parsed_args.credential, + ignore_missing=False + ) + + display_columns, columns = _get_columns(obj) + data = utils.get_item_properties(obj, columns) + + return (display_columns, data) + + +class DeleteCredential(command.Command): + _description = _('Delete identity credential') + + def get_parser(self, prog_name): + parser = super(DeleteCredential, self).get_parser(prog_name) + + parser.add_argument( + 'credential', + metavar='', + nargs='+', + help=_('Access key of the credential.') + ) + + return parser + + def take_action(self, parsed_args): + if parsed_args.credential: + client = self.app.client_manager.identity + for credential in parsed_args.credential: + credential = client.find_credential( + credential, + ignore_missing=False) + client.delete_credential(credential=credential) + + +class UpdateCredential(command.ShowOne): + _description = _("Update identity credential.") + + def get_parser(self, prog_name): + parser = super(UpdateCredential, self).get_parser(prog_name) + parser.add_argument( + 'credential', + metavar='', + help=_("Specifies the access key / ID of the credential."), + ) + parser.add_argument( + '--description', + metavar='', + help=_("Provides supplementary information about the credential."), + ) + parser.add_argument( + '--status', + metavar='', + help=_('Switch status of the credential.\n' + 'active: Credential is active\n' + 'inactive: Credential is inactive\n'), + ) + return parser + + def take_action(self, parsed_args): + client = self.app.client_manager.identity + args_list = [ + 'description', 'status' + ] + attrs = {} + for arg in args_list: + if getattr(parsed_args, arg): + attrs[arg] = getattr(parsed_args, arg) + credential = client.find_credential(parsed_args.credential) + + obj = client.update_credential(credential.id, **attrs) + + display_columns, columns = _get_columns(obj) + data = utils.get_item_properties(obj, columns) + + return (display_columns, data) + + +class CreateCredential(command.ShowOne): + _description = _('Create a identity credential') + + def get_parser(self, prog_name): + parser = super(CreateCredential, self).get_parser(prog_name) + + parser.add_argument( + 'user_id', + metavar='', + help=_('User ID of the user who will use the credential') + ) + parser.add_argument( + '--description', + metavar='', + help=_('Description of the alarm') + ) + + return parser + + def take_action(self, parsed_args): + + client = self.app.client_manager.identity + + attrs = {} + + attrs['user_id'] = parsed_args.user_id + if parsed_args.description: + attrs['description'] = parsed_args.description + + obj = client.create_credential( + **attrs + ) + + display_columns, columns = _get_columns(obj) + data = utils.get_item_properties(obj, columns) + + return (display_columns, data) diff --git a/otcextensions/sdk/__init__.py b/otcextensions/sdk/__init__.py index 42ee62e0d..315f207bf 100644 --- a/otcextensions/sdk/__init__.py +++ b/otcextensions/sdk/__init__.py @@ -102,7 +102,8 @@ 'service_type': 'ecs', }, 'identity': { - 'service_type': 'identity' + 'service_type': 'identity', + 'replace_system': True }, 'kms': { 'service_type': 'kms', diff --git a/otcextensions/sdk/identity/v3/_bad_base.py b/otcextensions/sdk/identity/v3/_bad_base.py new file mode 100644 index 000000000..76f7cadc2 --- /dev/null +++ b/otcextensions/sdk/identity/v3/_bad_base.py @@ -0,0 +1,39 @@ +# 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. +from urllib.parse import urlparse + +from openstack import resource +from openstack.utils import urljoin + + +class BadBaseResource(resource.Resource): + """A base class for all terribly exposed non Keystone native extensions + """ + + def __init__(self, _synchronized=False, connection=None, **attrs): + super(BadBaseResource, self).__init__( + _synchronized=_synchronized, + connection=connection, **attrs) + if connection: + # Hopefully we land here when creating/fetching resource. If this + # is the case - try to modify base_url of the resource to be + # pointing to the FQDN instead of relative URL due to a completely + # insane service publishing. + # We need this for the operations, which doesn't support overriding + # base_path from proxy layer + identity_url = connection.identity.get_endpoint_data().url + parsed_domain = urlparse(identity_url) + self.__base = '%s://%s' % ( + parsed_domain.scheme, parsed_domain.netloc) + self.base_path = urljoin( + self.__base, + self.base_path) diff --git a/otcextensions/sdk/identity/v3/_proxy.py b/otcextensions/sdk/identity/v3/_proxy.py index 11be6eb40..dcae3da0e 100644 --- a/otcextensions/sdk/identity/v3/_proxy.py +++ b/otcextensions/sdk/identity/v3/_proxy.py @@ -15,6 +15,8 @@ from openstack.identity.v3 import _proxy +from otcextensions.sdk.identity.v3 import agency as _agency +from otcextensions.sdk.identity.v3 import agency_role as _agency_role from otcextensions.sdk.identity.v3 import credential as _credential @@ -24,7 +26,7 @@ def __init__(self, session, *args, **kwargs): super(Proxy, self).__init__(session=session, *args, **kwargs) self._credentials_base = None - def _get_credentials_endpoint(self): + def _get_alternate_endpoint(self): if not self._credentials_base: identity_url = self.get_endpoint_data().url parsed_domain = urlparse(identity_url) @@ -45,7 +47,7 @@ def credentials(self, **attrs): instances """ # for list we need to pass corrected endpoint - base = self._get_credentials_endpoint() + base = self._get_alternate_endpoint() base_path = urljoin(base, _credential.Credential.base_path) return self._list(_credential.Credential, base_path=base_path, **attrs) @@ -85,8 +87,11 @@ def find_credential(self, name_or_id, ignore_missing=True, **attrs): :returns: ``None`` """ + base = self._get_alternate_endpoint() + base_path = urljoin(base, _credential.Credential.base_path) return self._find(_credential.Credential, name_or_id, ignore_missing=ignore_missing, + base_path=base_path, **attrs) def delete_credential(self, credential, ignore_missing=True): @@ -119,3 +124,305 @@ def update_credential(self, credential, **attrs): :rtype: :class:`~otcextensions.sdk.identity.v3.credential.Credential` """ return self._update(_credential.Credential, credential, **attrs) + + # ========== Agencies ========== + def _agency_normalize_domain_id(self, **attrs): + if 'domain_id' not in attrs: + # User missed passing domain_id. Let's assume he wants to list own + # agencies. + # Otherwise he should have passed domain_id=None + access = self.session.auth.get_auth_ref(self) + if access.domain_id: + attrs['domain_id'] = access.domain_id + elif access.project_domain_id: + attrs['domain_id'] = access.project_domain_id + elif access.user_domain_id: + attrs['domain_id'] = access.user_domain_id + elif attrs['domain_id'] is None: + # even though requests will eject this prop, do not rely on this + # functionality + attrs.pop('domain_id') + return attrs + + def agencies(self, **attrs): + """Retrieve a generator of agencies + + When domain_id query parameter is not set - current domain_id will be + used. Passing domain_id=None allow removing filtering. + + :param dict attrs: Optional query parameters to be sent to limit the + resources being returned. + * `domain_id`: Current domain ID + * `name`: Name of the agency + * `trust_domain_id`: ID of the delegated domain. + + :returns: A generator of agencies + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + instances + """ + # for list we need to pass corrected endpoint + base = self._get_alternate_endpoint() + base_path = urljoin(base, _agency.Agency.base_path) + attrs = self._agency_normalize_domain_id(**attrs) + + return self._list(_agency.Agency, base_path=base_path, + **attrs) + + def create_agency(self, **attrs): + """Create a new agency from attributes + + :param dict attrs: Keyword arguments which will be used to create + a :class:`~otcextensions.sdk.identity.v3.agency.Agency`, + comprised of the properties on the Agency class. + :returns: The results of agency creation + :rtype: :class:`~otcextensions.sdk.identity.v3.agency.Agency` + """ + attrs = self._agency_normalize_domain_id(**attrs) + return self._create(_agency.Agency, prepend_key=True, + **attrs) + + def get_agency(self, agency): + """Get a agency + + :param agency: The value can be the ID of a agency + or a :class:`~otcextensions.sdk.identity.v3.agency.Agency` + instance. + :returns: Agency instance + :rtype: :class:`~otcextensions.sdk.identity.v3.agency.Agency` + """ + return self._get(_agency.Agency, agency) + + def find_agency(self, name_or_id, ignore_missing=True, **attrs): + """Find a single agency + + :param name_or_id: The name or ID of a agency + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised + when the agency does not exist. + When set to ``True``, no exception will be set when attempting + to delete a nonexistent agency. + + :returns: ``None`` + """ + base = self._get_alternate_endpoint() + base_path = urljoin(base, _agency.Agency.base_path) + attrs = self._agency_normalize_domain_id(**attrs) + return self._find(_agency.Agency, name_or_id, + ignore_missing=ignore_missing, + base_path=base_path, + **attrs) + + def delete_agency(self, agency, ignore_missing=True): + """Delete a agency + + :param agency: The value can be the ID of a agency + or a :class:`~otcextensions.sdk.identity.v3.agency.Agency` + instance. + :param bool ignore_missing: When set to ``False`` + :class:`~openstack.exceptions.ResourceNotFound` will be raised when + the agency does not exist. + When set to ``True``, no exception will be set when attempting to + delete a nonexistent agency. + + :returns: Agency been deleted + :rtype: :class:`~otcextensions.sdk.identity.v3.agency.Agency` + """ + return self._delete(_agency.Agency, + agency, + ignore_missing=ignore_missing) + + def update_agency(self, agency, **attrs): + """Update agency attributes + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param dict attrs: attributes for update on + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + + :rtype: :class:`~otcextensions.sdk.identity.v3.agency.Agency` + """ + return self._update(_agency.Agency, agency, **attrs) + + # ========== Agency roles ========== + + def agency_project_roles(self, agency, project_id): + """Retrieve a generator of agency roles on a project + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param project_id: ID of a project + + :returns: A generator of agencies + :class:`~otcextensions.sdk.identity.v3.agency_role.AgencyRole` + instances + """ + # for list we need to pass corrected endpoint + base = self._get_alternate_endpoint() + base_path = urljoin(base, _agency_role.AgencyRole.base_path) + agency = self._get_resource(_agency.Agency, agency) + + return self._list(_agency_role.AgencyRole, + base_path=base_path, + role_ref_type='project', + role_ref_id=project_id, + agency_id=agency.id) + + def check_agency_project_role(self, agency, project_id, role_id): + """Check whether role is granted on the project through agency + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param project_id: ID of a project + :param role_id: ID of a role to check + + :returns: + :class:`~otcextensions.sdk.identity.v3.agency_role.AgencyRole` + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'project', + 'role_ref_id': project_id, + 'id': role_id + } + ) + return self._head(_agency_role.AgencyRole, + agency_role) + + def grant_agency_project_role(self, agency, project_id, role_id): + """Grant permission of agency on a project + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param project_id: ID of a project + :param role_id: ID of a role to revoke + + :returns: + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'project', + 'role_ref_id': project_id, + 'id': role_id + } + ) + req = agency_role._prepare_request() + self.put(req.url) + + def revoke_agency_project_role(self, agency, project_id, role_id): + """Revoke permission of agency on a project + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param project_id: ID of a project + :param role_id: ID of a role to revoke + + :returns: + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'project', + 'role_ref_id': project_id, + 'id': role_id + } + ) + return self._delete(_agency_role.AgencyRole, agency_role) + + def agency_domain_roles(self, agency, domain_id): + """Retrieve a generator of agency roles on a domain + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param domain_id: ID of a domain + + :returns: A generator of agencies + :class:`~otcextensions.sdk.identity.v3.agency_role.AgencyRole` + instances + """ + # for list we need to pass corrected endpoint + base = self._get_alternate_endpoint() + base_path = urljoin(base, _agency_role.AgencyRole.base_path) + agency = self._get_resource(_agency.Agency, agency) + + return self._list(_agency_role.AgencyRole, + base_path=base_path, + role_ref_type='domain', + role_ref_id=domain_id, + agency_id=agency.id) + + def check_agency_domain_role(self, agency, domain_id, role_id): + """Check whether role is granted on the domain through agency + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param domain_id: ID of a domain + :param role_id: ID of a role to check + + :returns: + :class:`~otcextensions.sdk.identity.v3.agency_role.AgencyRole` + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'domain', + 'role_ref_id': domain_id, + 'id': role_id + } + ) + return self._head(_agency_role.AgencyRole, + agency_role) + + def grant_agency_domain_role(self, agency, domain_id, role_id): + """Grant permission of agency on a domain + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param domain_id: ID of a domain + :param role_id: ID of a role to revoke + + :returns: + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'domain', + 'role_ref_id': domain_id, + 'id': role_id + } + ) + req = agency_role._prepare_request() + self.put(req.url) + + def revoke_agency_domain_role(self, agency, domain_id, role_id): + """Revoke permission of agency on a domain + + :param agency: The id or an instance of + :class:`~otcextensions.sdk.identity.v3.agency.Agency` + :param domain_id: ID of a domain + :param role_id: ID of a role to revoke + + :returns: + """ + agency = self._get_resource(_agency.Agency, agency) + agency_role = self._get_resource( + _agency_role.AgencyRole, + { + 'agency_id': agency.id, + 'role_ref_type': 'domain', + 'role_ref_id': domain_id, + 'id': role_id + } + ) + return self._delete(_agency_role.AgencyRole, agency_role) diff --git a/otcextensions/sdk/identity/v3/agency.py b/otcextensions/sdk/identity/v3/agency.py new file mode 100644 index 000000000..d03e2c951 --- /dev/null +++ b/otcextensions/sdk/identity/v3/agency.py @@ -0,0 +1,50 @@ +# 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. + +from openstack import resource + +from otcextensions.sdk.identity.v3 import _bad_base as _base + + +class Agency(_base.BadBaseResource): + resource_key = 'agency' + resources_key = 'agencies' + base_path = '/v3.0/OS-AGENCY/agencies' + + # capabilities + allow_create = True + allow_fetch = True + allow_commit = True + allow_delete = True + allow_list = True + commit_method = 'PUT' + + _query_mapping = resource.QueryParameters( + 'domain_id', 'name', 'trust_domain_id' + ) + + # Properties + #: Time when an agency is created. + created_at = resource.Body('create_time') + #: Description of an agency. + description = resource.Body('description') + #: ID of the current domain. + domain_id = resource.Body('domain_id') + #: Validity period of an agency. The default value is null, indicating that + #: the agency is permanently valid. + duration = resource.Body('duration') + #: Expiration time of an agency. + expire_at = resource.Body('expire_time') + #: ID of the delegated domain. + trust_domain_id = resource.Body('trust_domain_id') + #: Name of the delegated domain. + trust_domain_name = resource.Body('trust_domain_name') diff --git a/otcextensions/sdk/identity/v3/agency_role.py b/otcextensions/sdk/identity/v3/agency_role.py new file mode 100644 index 000000000..2b2c4ef11 --- /dev/null +++ b/otcextensions/sdk/identity/v3/agency_role.py @@ -0,0 +1,45 @@ +# 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. + +from openstack import resource + +from otcextensions.sdk.identity.v3 import _bad_base as _base + + +class AgencyRole(_base.BadBaseResource): + resources_key = 'roles' + base_path = ('/v3.0/OS-AGENCY/%(role_ref_type)ss/%(role_ref_id)s' + '/agencies/%(agency_id)s/roles') + + # capabilities + allow_commit = True + allow_head = True + allow_delete = True + allow_list = True + + # Properties + role_ref_type = resource.URI('role_ref_type') + role_ref_id = resource.URI('role_ref_id') + agency_id = resource.URI('agency_id') + + #: Directory where a role locates. + catalog = resource.Body('catalog') + #: Description of the role. + description = resource.Body('description') + #: ID of the domain to which a role belongs. + domain_id = resource.Body('domain_id') + #: Name of a role. + name = resource.Body('display_name') + #: Policy of a role. + policy = resource.Body('policy', type=dict) + #: Display mode of a role. + type = resource.Body('type') diff --git a/otcextensions/sdk/identity/v3/credential.py b/otcextensions/sdk/identity/v3/credential.py index 12ee38395..4e8156be9 100644 --- a/otcextensions/sdk/identity/v3/credential.py +++ b/otcextensions/sdk/identity/v3/credential.py @@ -9,13 +9,13 @@ # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. -from urllib.parse import urlparse from openstack import resource -from openstack.utils import urljoin +from otcextensions.sdk.identity.v3 import _bad_base as _base -class Credential(resource.Resource): + +class Credential(_base.BadBaseResource): resource_key = 'credential' resources_key = 'credentials' base_path = '/v3.0/OS-CREDENTIAL/credentials' @@ -45,21 +45,3 @@ class Credential(resource.Resource): status = resource.Body('status') #: References the user ID which owns the credential. *Type: string* user_id = resource.Body('user_id') - - def __init__(self, _synchronized=False, connection=None, **attrs): - super(Credential, self).__init__(_synchronized=_synchronized, - connection=connection, **attrs) - if connection: - # Hopefully we land here when creating/fetching resource. If this - # is the case - try to modify base_url of the resource to be - # pointing to the FQDN instead of relative URL due to a completely - # insane service publishing. - # We need this for the operations, which doesn't support overriding - # base_path from proxy layer - identity_url = connection.identity.get_endpoint_data().url - parsed_domain = urlparse(identity_url) - self._credentials_base = '%s://%s' % ( - parsed_domain.scheme, parsed_domain.netloc) - self.base_path = urljoin( - self._credentials_base, - self.base_path) diff --git a/otcextensions/tests/unit/common/test_agency_auth.py b/otcextensions/tests/unit/common/test_agency_auth.py new file mode 100644 index 000000000..af74c119f --- /dev/null +++ b/otcextensions/tests/unit/common/test_agency_auth.py @@ -0,0 +1,101 @@ +# 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 time + +from keystoneauth1 import session +from keystoneauth1.tests.unit import utils + +from otcextensions.common import agency_auth + + +class AgencyTest(utils.TestCase): + + TEST_ROOT_URL = 'http://127.0.0.1:5000/' + TEST_URL = '%s%s' % (TEST_ROOT_URL, 'v3') + TEST_ROOT_ADMIN_URL = 'http://127.0.0.1:35357/' + TEST_ADMIN_URL = '%s%s' % (TEST_ROOT_ADMIN_URL, 'v3') + + TEST_PASS = 'password' + + TEST_APP_CRED_ID = 'appcredid' + TEST_APP_CRED_SECRET = 'secret' + TEST_TARGET_DOMAIN_ID = 'agency_domain_id' + TEST_TARGET_AGENCY_NAME = 'agency_name' + TEST_TARGET_PROJECT_ID = 'target_project_id' + + def setUp(self): + super(AgencyTest, self).setUp() + nextyear = 1 + time.gmtime().tm_year + self.TEST_RESPONSE_DICT = { + "token": { + "methods": [ + "token", + "password" + ], + + "expires_at": "%i-02-01T00:00:10.000123Z" % nextyear, + "project": { + "domain": { + "id": self.TEST_DOMAIN_ID, + "name": self.TEST_DOMAIN_NAME + }, + "id": self.TEST_TENANT_ID, + "name": self.TEST_TENANT_NAME + }, + "user": { + "domain": { + "id": self.TEST_DOMAIN_ID, + "name": self.TEST_DOMAIN_NAME + }, + "id": self.TEST_USER, + "name": self.TEST_USER + }, + "issued_at": "2013-05-29T16:55:21.468960Z", + "catalog": [], # self.TEST_SERVICE_CATALOG, + "service_providers": [] # self.TEST_SERVICE_PROVIDERS + }, + } + + def stub_auth(self, subject_token=None, **kwargs): + if not subject_token: + subject_token = self.TEST_TOKEN + + self.stub_url('POST', ['auth', 'tokens'], + headers={'X-Subject-Token': subject_token}, **kwargs) + + def test_authenticate_with_username_password(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + a = agency_auth.Agency(self.TEST_URL, + username=self.TEST_USER, + password=self.TEST_PASS, + target_domain_id=self.TEST_TARGET_DOMAIN_ID, + target_agency_name=self.TEST_TARGET_AGENCY_NAME, + target_project_id=self.TEST_TARGET_PROJECT_ID) + self.assertFalse(a.has_scope_parameters) + s = session.Session(auth=a) + + self.assertEqual({'X-Auth-Token': self.TEST_TOKEN}, + s.get_auth_headers()) + + req = {'auth': {'identity': + {'methods': ['assume_role'], + 'assume_role': {'domain_id': self.TEST_TARGET_DOMAIN_ID, + 'xrole_name': self.TEST_TARGET_AGENCY_NAME}}, + 'scope': {'project': {'id': self.TEST_TARGET_PROJECT_ID}}}} + + self.assertRequestBodyIs(json=req) + self.assertRequestHeaderEqual('Content-Type', 'application/json') + self.assertRequestHeaderEqual('Accept', 'application/json') + self.assertRequestHeaderEqual('X-Auth-Token', self.TEST_TOKEN) + self.assertEqual(s.auth.auth_ref.auth_token, self.TEST_TOKEN) + # TODO(not_gtema): add other tests diff --git a/otcextensions/tests/unit/osclient/identity/__init__.py b/otcextensions/tests/unit/osclient/identity/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/otcextensions/tests/unit/osclient/identity/v3/__init__.py b/otcextensions/tests/unit/osclient/identity/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/otcextensions/tests/unit/osclient/identity/v3/fakes.py b/otcextensions/tests/unit/osclient/identity/v3/fakes.py new file mode 100644 index 000000000..5b203302a --- /dev/null +++ b/otcextensions/tests/unit/osclient/identity/v3/fakes.py @@ -0,0 +1,67 @@ +# 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 uuid +from datetime import datetime + +import mock + +from openstackclient.tests.unit import utils + +from otcextensions.tests.unit.osclient import test_base + +from otcextensions.sdk.identity.v3 import credential + + +def gen_data(data, columns): + """Fill expected data tuple based on columns list + """ + return tuple(getattr(data, attr, '') for attr in columns) + + +def gen_data_dict(data, columns): + """Fill expected data tuple based on columns list + """ + return tuple(data.get(attr, '') for attr in columns) + + +class TestIdentity(utils.TestCommand): + def setUp(self): + super(TestIdentity, self).setUp() + + self.app.client_manager.identity = mock.Mock() + + self.client = self.app.client_manager.identity + + +class FakeIdentityCredential(test_base.Fake): + """Fake one or more identity credentials.""" + @classmethod + def generate(cls): + """Create a fake identity credential. + + :return: + A FakeResource object, with id, name and so on + """ + # Set default attributes. + + access = "id-" + uuid.uuid4().hex + + object_info = { + "access": access, + "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f"), + "description": "my credential", + "status": "active", + "user_id": "user-id-" + uuid.uuid4().hex, + } + + return credential.Credential(**object_info) diff --git a/otcextensions/tests/unit/osclient/identity/v3/test_credential.py b/otcextensions/tests/unit/osclient/identity/v3/test_credential.py new file mode 100644 index 000000000..67c6e90c3 --- /dev/null +++ b/otcextensions/tests/unit/osclient/identity/v3/test_credential.py @@ -0,0 +1,97 @@ +# 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 mock +# from unittest.mock import call + +# from osc_lib import exceptions + +from otcextensions.osclient.identity.v3 import credential +from otcextensions.tests.unit.osclient.identity.v3 import fakes + +# from openstackclient.tests.unit import utils as tests_utils + + +class TestListIdentityCredentials(fakes.TestIdentity): + + objects = fakes.FakeIdentityCredential.create_multiple(3) + + column_list_headers = ( + 'Access Key', + 'Description', + 'User ID', + 'Status', + 'Created At', + ) + + columns = ( + 'access', + 'description', + 'user_id', + 'status', + 'created_at', + ) + + data = [] + + for o in objects: + data.append( + (o.id, o.description, o.user_id, o.status, o.created_at)) + + def setUp(self): + super(TestListIdentityCredentials, self).setUp() + + self.cmd = credential.ListCredentials(self.app, None) + + self.client.credentials = mock.Mock() + self.client.api_mock = self.client.credentials + + def test_list(self): + arglist = [] + + verifylist = [] + + # Verify cm is triggered with default parameters + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # Set the response + self.client.api_mock.side_effect = [self.objects] + + # Trigger the action + columns, data = self.cmd.take_action(parsed_args) + + self.client.api_mock.assert_called_with() + + self.assertEqual(self.column_list_headers, columns) + self.assertEqual(self.data, list(data)) + + def test_list_args(self): + arglist = [ + '--user-id', '1' + ] + + verifylist = [ + ('user_id', '1'), + ] + + # Verify cm is triggered with default parameters + parsed_args = self.check_parser(self.cmd, arglist, verifylist) + + # Set the response + self.client.api_mock.side_effect = [self.objects] + + # Trigger the action + columns, data = self.cmd.take_action(parsed_args) + + self.client.api_mock.assert_called_with( + user_id='1', + ) diff --git a/otcextensions/tests/unit/sdk/identity/v3/test_agency.py b/otcextensions/tests/unit/sdk/identity/v3/test_agency.py new file mode 100644 index 000000000..9a29becaa --- /dev/null +++ b/otcextensions/tests/unit/sdk/identity/v3/test_agency.py @@ -0,0 +1,72 @@ +# 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. + +from openstack.tests.unit import base + +from otcextensions.sdk.identity.v3 import agency + + +FAKE_ID = "945d-fe449be00148" +EXAMPLE = { + "trust_domain_name": "exampledomain", + "description": " testsfdas ", + "trust_domain_id": "b3f266d0c08544a0859740de8b84e850", + "id": "afca8ddf2e92469a8fd26a635da5206f", + "duration": None, + "create_time": "2017-01-04T09:09:15.000000", + "expire_time": None, + "domain_id": "0ae9c6993a2e47bb8c4c7a9bb8278d61", + "name": "exampleagency" +} + + +class TestAgency(base.TestCase): + + def setUp(self): + super(TestAgency, self).setUp() + + def test_basic(self): + sot = agency.Agency() + + self.assertEqual('/v3.0/OS-AGENCY/agencies', sot.base_path) + self.assertEqual('agencies', sot.resources_key) + self.assertEqual('agency', sot.resource_key) + + self.assertTrue(sot.allow_list) + self.assertTrue(sot.allow_fetch) + self.assertTrue(sot.allow_create) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_commit) + + self.assertDictEqual({ + 'domain_id': 'domain_id', + 'limit': 'limit', + 'marker': 'marker', + 'name': 'name', + 'trust_domain_id': 'trust_domain_id'}, + sot._query_mapping._mapping + ) + + def test_make_it(self): + + sot = agency.Agency(connection=self.cloud, **EXAMPLE) + # Check how the override with "real" connection works + self.assertEqual( + 'https://identity.example.com/v3.0/OS-AGENCY/agencies', + sot.base_path) + + self.assertEqual(EXAMPLE['id'], sot.id) + self.assertEqual(EXAMPLE['description'], sot.description) + self.assertEqual(EXAMPLE['trust_domain_id'], sot.trust_domain_id) + self.assertEqual(EXAMPLE['create_time'], sot.created_at) + self.assertEqual(EXAMPLE['expire_time'], sot.expire_at) + self.assertEqual(EXAMPLE['domain_id'], sot.domain_id) diff --git a/otcextensions/tests/unit/sdk/identity/v3/test_agency_role.py b/otcextensions/tests/unit/sdk/identity/v3/test_agency_role.py new file mode 100644 index 000000000..57f79a86c --- /dev/null +++ b/otcextensions/tests/unit/sdk/identity/v3/test_agency_role.py @@ -0,0 +1,65 @@ +# 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 mock + +from keystoneauth1 import adapter + +from openstack.tests.unit import base + +from otcextensions.sdk.identity.v3 import agency_role + + +FAKE_ID = "945d-fe449be00148" +EXAMPLE = { + "catalog": "BASE", + "display_name": "Tenant Guest", + "name": "readonly", + "policy": {}, + "domain_id": None, + "type": "AA", + "id": "b32d99a7778d4fd9aa5bc616c3dc4e5f", + "description": "Tenant Guest" +} + + +class TestAgencyRole(base.TestCase): + + def setUp(self): + super(TestAgencyRole, self).setUp() + self.sess = mock.Mock(spec=adapter.Adapter) + self.sess.post = mock.Mock() + + def test_basic(self): + sot = agency_role.AgencyRole() + + self.assertEqual( + ('/v3.0/OS-AGENCY/%(role_ref_type)ss' + '/%(role_ref_id)s/agencies/%(agency_id)s/roles'), + sot.base_path) + self.assertEqual('roles', sot.resources_key) + + self.assertTrue(sot.allow_list) + self.assertTrue(sot.allow_head) + self.assertTrue(sot.allow_delete) + self.assertTrue(sot.allow_commit) + + def test_make_it(self): + + sot = agency_role.AgencyRole(connection=self.cloud, **EXAMPLE) + # Check how the override with "real" connection works + self.assertEqual( + ('https://identity.example.com/v3.0/OS-AGENCY/%(role_ref_type)ss' + '/%(role_ref_id)s/agencies/%(agency_id)s/roles'), + sot.base_path) + + self.assertEqual(EXAMPLE['id'], sot.id) + self.assertEqual(EXAMPLE['description'], sot.description) diff --git a/otcextensions/tests/unit/sdk/identity/v3/test_credential.py b/otcextensions/tests/unit/sdk/identity/v3/test_credential.py index 73cb8de4c..e5acbe0d6 100644 --- a/otcextensions/tests/unit/sdk/identity/v3/test_credential.py +++ b/otcextensions/tests/unit/sdk/identity/v3/test_credential.py @@ -9,9 +9,6 @@ # 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 mock - -from keystoneauth1 import adapter from openstack.tests.unit import base @@ -32,8 +29,6 @@ class TestCredential(base.TestCase): def setUp(self): super(TestCredential, self).setUp() - self.sess = mock.Mock(spec=adapter.Adapter) - self.sess.post = mock.Mock() def test_basic(self): sot = credential.Credential() @@ -49,6 +44,13 @@ def test_basic(self): self.assertTrue(sot.allow_delete) self.assertTrue(sot.allow_commit) + self.assertDictEqual({ + 'limit': 'limit', + 'marker': 'marker', + 'user_id': 'user_id'}, + sot._query_mapping._mapping + ) + def test_make_it(self): sot = credential.Credential(connection=self.cloud, **EXAMPLE) diff --git a/otcextensions/tests/unit/sdk/identity/v3/test_proxy.py b/otcextensions/tests/unit/sdk/identity/v3/test_proxy.py index a1507339e..a34d95370 100644 --- a/otcextensions/tests/unit/sdk/identity/v3/test_proxy.py +++ b/otcextensions/tests/unit/sdk/identity/v3/test_proxy.py @@ -14,6 +14,8 @@ from otcextensions.sdk.identity.v3 import _proxy from otcextensions.sdk.identity.v3 import credential +from otcextensions.sdk.identity.v3 import agency +from otcextensions.sdk.identity.v3 import agency_role from openstack.tests.unit import test_proxy_base @@ -35,16 +37,19 @@ def test_credential_delete(self): self.verify_delete(self.proxy.delete_credential, credential.Credential, True) - def test_credential_find(self): + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_credential_find(self, epo_mock): self.verify_find(self.proxy.find_credential, credential.Credential) def test_credential_get(self): self.verify_get(self.proxy.get_credential, credential.Credential) @mock.patch( - 'otcextensions.sdk.identity.v3._proxy.Proxy._get_credentials_endpoint') + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') def test_credentials(self, epo_mock): - epo_mock.return_value = 'fake' self.verify_list( self.proxy.credentials, credential.Credential, @@ -53,3 +58,294 @@ def test_credentials(self, epo_mock): def test_credential_update(self): self.verify_update(self.proxy.update_credential, credential.Credential) + + +class TestIdentityAgency(TestIdentityProxy): + def test__agencynormalize_domain(self): + with mock.patch.object(self.proxy, 'session') as s_mock: + access_mock = mock.Mock() + access_mock.domain_id = 'fake_domain' + s_mock.auth = mock.Mock() + s_mock.auth.get_auth_ref = mock.Mock( + return_value=access_mock + ) + + self.assertDictEqual({ + 'domain_id': 'fake_domain' + }, self.proxy._agency_normalize_domain_id() + ) + + def test_agency_create(self): + self.verify_create(self.proxy.create_agency, agency.Agency, + method_kwargs={'name': 'id', 'domain_id': 'fake'}, + expected_kwargs={'name': 'id', + 'domain_id': 'fake', + 'prepend_key': True}) + + def test_agency_delete(self): + self.verify_delete(self.proxy.delete_agency, + agency.Agency, True) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_agency_find(self, epo_mock): + self.verify_find( + self.proxy.find_agency, agency.Agency, + method_kwargs={'domain_id': 'fake'}, + expected_kwargs={'domain_id': 'fake'} + ) + + def test_agency_get(self): + self.verify_get(self.proxy.get_agency, agency.Agency) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_agencies(self, epo_mock): + self.verify_list( + self.proxy.agencies, + agency.Agency, + method_kwargs={'domain_id': 'fake'}, + expected_kwargs={'domain_id': 'fake'} + ) + epo_mock.assert_called_with() + + def test_agency_update(self): + self.verify_update(self.proxy.update_agency, agency.Agency) + + +class TestIdentityAgencyProjectRoles(TestIdentityProxy): + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource', + return_value=agency.Agency(id='fake')) + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_agency_project_roles(self, epo_mock, gr_mock): + self.verify_list( + self.proxy.agency_project_roles, + agency_role.AgencyRole, + method_kwargs={ + 'agency': 'fake_agency', + 'project_id': 'fake_project' + }, + expected_kwargs={ + 'agency_id': 'fake', + 'role_ref_type': 'project', + 'role_ref_id': 'fake_project' + } + ) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_check_agency_project_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole(id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake + ] + expected_calls = [ + mock.call(agency.Agency, 'fake_agency'), + mock.call( + agency_role.AgencyRole, + {'agency_id': 'fake', + 'role_ref_type': 'project', + 'role_ref_id': 'fake_project', + 'id': 'fake_role'}) + ] + self._verify2( + 'openstack.proxy.Proxy._head', + self.proxy.check_agency_project_role, + method_kwargs={ + 'agency': 'fake_agency', + 'project_id': 'fake_project', + 'role_id': 'fake_role' + }, + expected_args=[ + agency_role.AgencyRole, + agency_role_fake + ], + ) + gr_mock.assert_has_calls(expected_calls) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_grant_agency_project_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole( + id='fake', + role_ref_type='project', + role_ref_id='fake_project', + agency_id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake, + ] + self._verify2( + 'openstack.proxy.Proxy.put', + self.proxy.grant_agency_project_role, + method_kwargs={ + 'agency': 'fake_agency', + 'project_id': 'fake_project', + 'role_id': 'fake_role' + }, + expected_args=[ + 'v3.0/OS-AGENCY/projects/fake_project/agencies/fake/roles/fake' + ], + ) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_revoke_agency_project_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole( + id='fake', + role_ref_type='project', + role_ref_id='fake_project', + agency_id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake, + ] + self._verify2( + 'openstack.proxy.Proxy._delete', + self.proxy.revoke_agency_project_role, + method_kwargs={ + 'agency': 'fake_agency', + 'project_id': 'fake_project', + 'role_id': 'fake_role' + }, + expected_args=[ + agency_role.AgencyRole, agency_role_fake + ], + ) + + +class TestIdentityAgencyDomainRoles(TestIdentityProxy): + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource', + return_value=agency.Agency(id='fake')) + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_agency_domain_roles(self, epo_mock, gr_mock): + self.verify_list( + self.proxy.agency_domain_roles, + agency_role.AgencyRole, + method_kwargs={ + 'agency': 'fake_agency', + 'domain_id': 'fake_domain' + }, + expected_kwargs={ + 'agency_id': 'fake', + 'role_ref_type': 'domain', + 'role_ref_id': 'fake_domain' + } + ) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_check_agency_domain_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole(id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake + ] + expected_calls = [ + mock.call(agency.Agency, 'fake_agency'), + mock.call( + agency_role.AgencyRole, + {'agency_id': 'fake', + 'role_ref_type': 'domain', + 'role_ref_id': 'fake_domain', + 'id': 'fake_role'}) + ] + self._verify2( + 'openstack.proxy.Proxy._head', + self.proxy.check_agency_domain_role, + method_kwargs={ + 'agency': 'fake_agency', + 'domain_id': 'fake_domain', + 'role_id': 'fake_role' + }, + expected_args=[ + agency_role.AgencyRole, + agency_role_fake + ], + ) + gr_mock.assert_has_calls(expected_calls) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_grant_agency_domain_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole( + id='fake', + role_ref_type='domain', + role_ref_id='fake_domain', + agency_id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake, + ] + self._verify2( + 'openstack.proxy.Proxy.put', + self.proxy.grant_agency_domain_role, + method_kwargs={ + 'agency': 'fake_agency', + 'domain_id': 'fake_domain', + 'role_id': 'fake_role' + }, + expected_args=[ + 'v3.0/OS-AGENCY/domains/fake_domain/agencies/fake/roles/fake' + ], + ) + + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_resource') + @mock.patch( + 'otcextensions.sdk.identity.v3._proxy.Proxy._get_alternate_endpoint', + return_value='fake') + def test_revoke_agency_domain_role(self, epo_mock, gr_mock): + agency_fake = agency.Agency(id='fake') + agency_role_fake = agency_role.AgencyRole( + id='fake', + role_ref_type='domain', + role_ref_id='fake_domain', + agency_id='fake') + gr_mock.side_effect = [ + agency_fake, + agency_role_fake, + ] + self._verify2( + 'openstack.proxy.Proxy._delete', + self.proxy.revoke_agency_domain_role, + method_kwargs={ + 'agency': 'fake_agency', + 'domain_id': 'fake_domain', + 'role_id': 'fake_role' + }, + expected_args=[ + agency_role.AgencyRole, agency_role_fake + ], + ) diff --git a/setup.cfg b/setup.cfg index 49fc72e13..2fcf88621 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,9 @@ packages = otcextensions [entry_points] +keystoneauth1.plugin = + agency = otcextensions.common.agency_auth:AgencyLoader + openstack.cli.extension = obs = otcextensions.osclient.obs.client rds = otcextensions.osclient.rds.client @@ -42,6 +45,7 @@ openstack.cli.extension = deh = otcextensions.osclient.deh.client nat = otcextensions.osclient.nat.client vpc = otcextensions.osclient.vpc.client + identity = otcextensions.osclient.identity.client #openstack.obs.v1 = # s3_ls = otcextensions.osclient.obs.v1.ls:List @@ -312,6 +316,13 @@ openstack.vpc.v2 = vpc_route_add = otcextensions.osclient.vpc.v2.route:AddVpcRoute vpc_route_delete = otcextensions.osclient.vpc.v2.route:DeleteVpcRoute +openstack.identity.v3 = + identity_credential_list = otcextensions.osclient.identity.v3.credential:ListCredentials + identity_credential_show = otcextensions.osclient.identity.v3.credential:ShowCredential + identity_credential_create = otcextensions.osclient.identity.v3.credential:CreateCredential + identity_credential_update = otcextensions.osclient.identity.v3.credential:UpdateCredential + identity_credential_delete = otcextensions.osclient.identity.v3.credential:DeleteCredential + [build_sphinx] builders = html,man all-files = 1 diff --git a/tox.ini b/tox.ini index a10573c81..099eae6b5 100644 --- a/tox.ini +++ b/tox.ini @@ -66,7 +66,7 @@ commands = [testenv:docs] deps = -c{env:UPPER_CONSTRAINTS_FILE:https://releases.openstack.org/constraints/upper/master} - -r{toxinidir}/requirements.txt +# -r{toxinidir}/requirements.txt -r{toxinidir}/doc/requirements.txt commands = sphinx-build -W -d doc/build/doctrees --keep-going -b html doc/source/ doc/build/html