From 4a392372a873c349bcba020f4ba1eb6d28e27a4e Mon Sep 17 00:00:00 2001 From: Gaetan2907 <48204380+Gaetan2907@users.noreply.github.com> Date: Mon, 19 Jul 2021 22:39:02 +0100 Subject: [PATCH] Keycloak: add clientscope management (#2905) * Add new keycloak_clienscope module * Add description and protocol parameter + Indentation Fix * Add protocolMappers parameter * Add documentation and Fix updatating of protocolMappers * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein * Add sanitize_cr(clientscoperep) function to sanitize the clientscope representation * Add unit tests for clientscope Keycloak module * Apply suggestions from code review Co-authored-by: Felix Fontein --- .../identity/keycloak/keycloak.py | 238 +++++++ .../identity/keycloak/keycloak_clientscope.py | 492 ++++++++++++++ plugins/modules/keycloak_clientscope.py | 1 + .../keycloak/test_keycloak_clientscope.py | 614 ++++++++++++++++++ 4 files changed, 1345 insertions(+) create mode 100644 plugins/modules/identity/keycloak/keycloak_clientscope.py create mode 120000 plugins/modules/keycloak_clientscope.py create mode 100644 tests/unit/plugins/modules/identity/keycloak/test_keycloak_clientscope.py diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 8521650f16..75ef2bba02 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -57,6 +57,11 @@ URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" URL_GROUPS = "{url}/admin/realms/{realm}/groups" URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes" +URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" +URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" +URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" + URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows" URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}" URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy" @@ -511,6 +516,239 @@ class KeycloakAPI(object): self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) + def get_clientscopes(self, realm="master"): + """ Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param realm: Realm in which the clientscope resides; default 'master'. + :return The clientscopes of this realm (default "master") + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(clientscopes_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of clientscopes in realm %s: %s" + % (realm, str(e))) + + def get_clientscope_by_clientscopeid(self, cid, realm="master"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(clientscope_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + % (cid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_clientscope_by_name(self, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param name: Name of the clientscope to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_clientscopes = self.get_clientscopes(realm=realm) + + for clientscope in all_clientscopes: + if clientscope['name'] == name: + return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope(self, clientscoperep, realm="master"): + """ Create a Keycloak clientscope. + + :param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return open_url(clientscopes_url, method='POST', headers=self.restheaders, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create clientscope %s in realm %s: %s" + % (clientscoperep['name'], realm, str(e))) + + def update_clientscope(self, clientscoperep, realm="master"): + """ Update an existing clientscope. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id']) + + try: + return open_url(clientscope_url, method='PUT', headers=self.restheaders, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg='Could not update clientscope %s in realm %s: %s' + % (clientscoperep['name'], realm, str(e))) + + def delete_clientscope(self, name=None, cid=None, realm="master"): + """ Delete a clientscope. One of name or cid must be provided. + + Providing the clientscope ID is preferred as it avoids a second lookup to + convert a clientscope name to an ID. + + :param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID. + :param cid: The ID of the clientscope (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if cid is None and name is None: + # prefer an exception since this is almost certainly a programming error in the module itself. + raise Exception("Unable to delete group - one of group ID or name must be provided.") + + # only lookup the name if cid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if cid is None and name is not None: + for clientscope in self.get_clientscopes(realm=realm): + if clientscope['name'] == name: + cid = clientscope['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if cid is None: + return None + + # should have a good cid by here. + clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl) + try: + return open_url(clientscope_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg="Unable to delete clientscope %s: %s" % (cid, str(e))) + + def get_clientscope_protocolmappers(self, cid, realm="master"): + """ Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param cid: id of clientscope (not name). + :param realm: Realm in which the clientscope resides; default 'master'. + :return The protocolmappers of this realm (default "master") + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(protocolmappers_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of protocolmappers in realm %s: %s" + % (realm, str(e))) + + def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + + :param cid: UUID of the protocolmapper to be returned + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid) + try: + return json.loads(to_native(open_url(protocolmapper_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (pid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param cid: Id of the clientscope (not name). + :param name: Name of the protocolmapper to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm) + + for protocolmapper in all_protocolmappers: + if protocolmapper['name'] == name: + return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"): + """ Create a Keycloak clientscope protocolmapper. + + :param cid: Id of the clientscope. + :param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm) + try: + return open_url(protocolmappers_url, method='POST', headers=self.restheaders, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create protocolmapper %s in realm %s: %s" + % (mapper_rep['name'], realm, str(e))) + + def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"): + """ Update an existing clientscope. + + :param cid: Id of the clientscope. + :param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper. + :return HTTPResponse object on success + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id']) + + try: + return open_url(protocolmapper_url, method='PUT', headers=self.restheaders, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s' + % (mapper_rep, realm, str(e))) + def get_groups(self, realm="master"): """ Fetch the name and ID of all groups on the Keycloak server. diff --git a/plugins/modules/identity/keycloak/keycloak_clientscope.py b/plugins/modules/identity/keycloak/keycloak_clientscope.py new file mode 100644 index 0000000000..c05050aae5 --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_clientscope.py @@ -0,0 +1,492 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_clientscope + +short_description: Allows administration of Keycloak client_scopes via Keycloak API + +version_added: 3.4.0 + +description: + - This module allows you to add, remove or modify Keycloak client_scopes via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + + - When updating a client_scope, where possible provide the client_scope ID to the module. This removes a lookup + to the API to translate the name into the client_scope ID. + + +options: + state: + description: + - State of the client_scope. + - On C(present), the client_scope will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the client_scope will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + description: + - Name of the client_scope. + - This parameter is required only when creating or updating the client_scope. + + realm: + type: str + description: + - They Keycloak realm under which this client_scope resides. + default: 'master' + + id: + type: str + description: + - The unique identifier for this client_scope. + - This parameter is not required for updating or deleting a client_scope but + providing it will reduce the number of API calls required. + + description: + type: str + description: + - Description for this client_scope. + - This parameter is not required for updating or deleting a client_scope. + + protocol: + description: + - Type of client. + choices: ['openid-connect', 'saml', 'wsfed'] + type: str + + protocol_mappers: + description: + - A list of dicts defining protocol mappers for this client. + - This is 'protocolMappers' in the Keycloak REST API. + aliases: + - protocolMappers + type: list + elements: dict + suboptions: + protocol: + description: + - This specifies for which protocol this protocol mapper + - is active. + choices: ['openid-connect', 'saml', 'wsfed'] + type: str + + protocolMapper: + description: + - "The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is + impossible to provide since this may be extended through SPIs by the user of Keycloak, + by default Keycloak as of 3.4 ships with at least:" + - C(docker-v2-allow-all-mapper) + - C(oidc-address-mapper) + - C(oidc-full-name-mapper) + - C(oidc-group-membership-mapper) + - C(oidc-hardcoded-claim-mapper) + - C(oidc-hardcoded-role-mapper) + - C(oidc-role-name-mapper) + - C(oidc-script-based-protocol-mapper) + - C(oidc-sha256-pairwise-sub-mapper) + - C(oidc-usermodel-attribute-mapper) + - C(oidc-usermodel-client-role-mapper) + - C(oidc-usermodel-property-mapper) + - C(oidc-usermodel-realm-role-mapper) + - C(oidc-usersessionmodel-note-mapper) + - C(saml-group-membership-mapper) + - C(saml-hardcode-attribute-mapper) + - C(saml-hardcode-role-mapper) + - C(saml-role-list-mapper) + - C(saml-role-name-mapper) + - C(saml-user-attribute-mapper) + - C(saml-user-property-mapper) + - C(saml-user-session-note-mapper) + - An exhaustive list of available mappers on your installation can be obtained on + the admin console by going to Server Info -> Providers and looking under + 'protocol-mapper'. + type: str + + name: + description: + - The name of this protocol mapper. + type: str + + id: + description: + - Usually a UUID specifying the internal ID of this protocol mapper instance. + type: str + + config: + description: + - Dict specifying the configuration options for the protocol mapper; the + contents differ depending on the value of I(protocolMapper) and are not documented + other than by the source of the mappers and its parent class(es). An example is given + below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the C(existing) return value. + type: dict + + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the client_scope. + - Values may be single values (for example a string) or a list of strings. + +extends_documentation_fragment: +- community.general.keycloak + + +author: + - Gaƫtan Daubresse (@Gaetan2907) +''' + +EXAMPLES = ''' +- name: Create a Keycloak client_scopes, authentication with credentials + community.general.keycloak_clientscope: + name: my-new-kc-clientscope + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a Keycloak client_scopes, authentication with token + community.general.keycloak_clientscope: + name: my-new-kc-clientscope + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost + +- name: Delete a keycloak client_scopes + community.general.keycloak_clientscope: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + state: absent + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Delete a Keycloak client_scope based on name + community.general.keycloak_clientscope: + name: my-clientscope-for-deletion + state: absent + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Update the name of a Keycloak client_scope + community.general.keycloak_clientscope: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + name: an-updated-kc-clientscope-name + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a Keycloak client_scope with some custom attributes + community.general.keycloak_clientscope: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new_clientscope + description: description-of-clientscope + protocol: openid-connect + protocol_mappers: + - config: + access.token.claim: True + claim.name: "family_name" + id.token.claim: True + jsonType.label: String + user.attribute: lastName + userinfo.token.claim: True + name: family name + protocol: openid-connect + protocolMapper: oidc-usermodel-property-mapper + - config: + attribute.name: Role + attribute.nameformat: Basic + single: false + name: role list + protocol: saml + protocolMapper: saml-role-list-mapper + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Client_scope testclientscope has been updated" + +proposed: + description: client_scope representation of proposed changes to client_scope + returned: always + type: dict + sample: { + clientId: "test" + } +existing: + description: client_scope representation of existing client_scope (sample is truncated) + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +end_state: + description: client_scope representation of client_scope after module execution (sample is truncated) + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError, is_struct_included +from ansible.module_utils.basic import AnsibleModule + + +def sanitize_cr(clientscoperep): + """ Removes probably sensitive details from a clientscoperep representation + + :param clientscoperep: the clientscoperep dict to be sanitized + :return: sanitized clientrep dict + """ + result = clientscoperep.copy() + if 'secret' in result: + result['secret'] = 'no_log' + if 'attributes' in result: + if 'saml.signing.private.key' in result['attributes']: + result['attributes']['saml.signing.private.key'] = 'no_log' + return result + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + protmapper_spec = dict( + id=dict(type='str'), + name=dict(type='str'), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']), + protocolMapper=dict(type='str'), + config=dict(type='dict'), + ) + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + id=dict(type='str'), + name=dict(type='str'), + description=dict(type='str'), + protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']), + attributes=dict(type='dict'), + protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['id', 'name'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + state = module.params.get('state') + cid = module.params.get('id') + name = module.params.get('name') + protocol_mappers = module.params.get('protocol_mappers') + + before_clientscope = None # current state of the clientscope, for merging. + + # does the clientscope already exist? + if cid is None: + before_clientscope = kc.get_clientscope_by_name(name, realm=realm) + else: + before_clientscope = kc.get_clientscope_by_clientscopeid(cid, realm=realm) + + before_clientscope = {} if before_clientscope is None else before_clientscope + + clientscope_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + module.params.get(x) is not None] + + # Build a proposed changeset from parameters given to this module + changeset = dict() + + for clientscope_param in clientscope_params: + new_param_value = module.params.get(clientscope_param) + + # some lists in the Keycloak API are sorted, some are not. + if isinstance(new_param_value, list): + if clientscope_param in ['attributes']: + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + # Unfortunately, the ansible argument spec checker introduces variables with null values when + # they are not specified + if clientscope_param == 'protocol_mappers': + new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + changeset[camel(clientscope_param)] = new_param_value + + # prepare the new clientscope + updated_clientscope = before_clientscope.copy() + updated_clientscope.update(changeset) + + # if before_clientscope is none, the clientscope doesn't exist. + if before_clientscope == {}: + if state == 'absent': + # nothing to do. + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'Clientscope does not exist; doing nothing.' + result['end_state'] = dict() + module.exit_json(**result) + + # for 'present', create a new clientscope. + result['changed'] = True + if name is None: + module.fail_json(msg='name must be specified when creating a new clientscope') + + if module._diff: + result['diff'] = dict(before='', after=sanitize_cr(updated_clientscope)) + + if module.check_mode: + module.exit_json(**result) + + # do it for real! + kc.create_clientscope(updated_clientscope, realm=realm) + after_clientscope = kc.get_clientscope_by_name(name, realm) + + result['end_state'] = sanitize_cr(after_clientscope) + result['msg'] = 'Clientscope {name} has been created with ID {id}'.format(name=after_clientscope['name'], + id=after_clientscope['id']) + + else: + if state == 'present': + # no changes + if updated_clientscope == before_clientscope: + result['changed'] = False + result['end_state'] = sanitize_cr(updated_clientscope) + result['msg'] = "No changes required to clientscope {name}.".format(name=before_clientscope['name']) + module.exit_json(**result) + + # update the existing clientscope + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_clientscope), after=sanitize_cr(updated_clientscope)) + + if module.check_mode: + module.exit_json(**result) + + # do the clientscope update + kc.update_clientscope(updated_clientscope, realm=realm) + + # do the protocolmappers update + if protocol_mappers is not None: + for protocol_mapper in protocol_mappers: + # update if protocolmapper exist + current_protocolmapper = kc.get_clientscope_protocolmapper_by_name(updated_clientscope['id'], protocol_mapper['name'], realm=realm) + if current_protocolmapper is not None: + protocol_mapper['id'] = current_protocolmapper['id'] + kc.update_clientscope_protocolmappers(updated_clientscope['id'], protocol_mapper, realm=realm) + # create otherwise + else: + kc.create_clientscope_protocolmapper(updated_clientscope['id'], protocol_mapper, realm=realm) + + after_clientscope = kc.get_clientscope_by_clientscopeid(updated_clientscope['id'], realm=realm) + + result['end_state'] = after_clientscope + result['msg'] = "Clientscope {id} has been updated".format(id=after_clientscope['id']) + + module.exit_json(**result) + + elif state == 'absent': + result['end_state'] = dict() + + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_clientscope), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete for real + cid = before_clientscope['id'] + kc.delete_clientscope(cid=cid, realm=realm) + + result['changed'] = True + result['msg'] = "Clientscope {name} has been deleted".format(name=before_clientscope['name']) + + module.exit_json(**result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_clientscope.py b/plugins/modules/keycloak_clientscope.py new file mode 120000 index 0000000000..01468a5c8e --- /dev/null +++ b/plugins/modules/keycloak_clientscope.py @@ -0,0 +1 @@ +identity/keycloak/keycloak_clientscope.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_clientscope.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_clientscope.py new file mode 100644 index 0000000000..0954562d95 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_clientscope.py @@ -0,0 +1,614 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from contextlib import contextmanager + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import call, patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, \ + ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules.identity.keycloak import keycloak_clientscope + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_clientscope_by_name=None, get_clientscope_by_clientscopeid=None, create_clientscope=None, + update_clientscope=None, get_clientscope_protocolmapper_by_name=None, + update_clientscope_protocolmappers=None, create_clientscope_protocolmapper=None, + delete_clientscope=None): + """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server + + Patches the `login` and `_post_json` methods + + Keyword arguments are passed to the mock object that patches `_post_json` + + No arguments are passed to the mock object that patches `login` because no tests require it + + Example:: + + with patch_ipa(return_value={}) as (mock_login, mock_post): + ... + """ + + """ + get_clientscope_by_clientscopeid + delete_clientscope + """ + + obj = keycloak_clientscope.KeycloakAPI + with patch.object(obj, 'get_clientscope_by_name', side_effect=get_clientscope_by_name) \ + as mock_get_clientscope_by_name: + with patch.object(obj, 'get_clientscope_by_clientscopeid', side_effect=get_clientscope_by_clientscopeid) \ + as mock_get_clientscope_by_clientscopeid: + with patch.object(obj, 'create_clientscope', side_effect=create_clientscope) \ + as mock_create_clientscope: + with patch.object(obj, 'update_clientscope', return_value=update_clientscope) \ + as mock_update_clientscope: + with patch.object(obj, 'get_clientscope_protocolmapper_by_name', + side_effect=get_clientscope_protocolmapper_by_name) \ + as mock_get_clientscope_protocolmapper_by_name: + with patch.object(obj, 'update_clientscope_protocolmappers', + side_effect=update_clientscope_protocolmappers) \ + as mock_update_clientscope_protocolmappers: + with patch.object(obj, 'create_clientscope_protocolmapper', + side_effect=create_clientscope_protocolmapper) \ + as mock_create_clientscope_protocolmapper: + with patch.object(obj, 'delete_clientscope', side_effect=delete_clientscope) \ + as mock_delete_clientscope: + yield mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, \ + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, mock_update_clientscope_protocolmappers, \ + mock_create_clientscope_protocolmapper, mock_delete_clientscope + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response( + object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + + def _create_wrapper(): + return StringIO(text_as_string) + + return _create_wrapper + + +def mock_good_connection(): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "alongtoken"}'), } + return patch( + 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +class TestKeycloakAuthentication(ModuleTestCase): + def setUp(self): + super(TestKeycloakAuthentication, self).setUp() + self.module = keycloak_clientscope + + def test_create_clientscope(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'present', + 'name': 'my-new-kc-clientscope' + } + return_value_get_clientscope_by_name = [ + None, + { + "attributes": {}, + "id": "73fec1d2-f032-410c-8177-583104d01305", + "name": "my-new-kc-clientscope" + }] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 2) + self.assertEqual(mock_create_clientscope.call_count, 1) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0) + self.assertEqual(mock_update_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_create_clientscope_idempotency(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'present', + 'name': 'my-new-kc-clientscope' + } + return_value_get_clientscope_by_name = [{ + "attributes": {}, + "id": "73fec1d2-f032-410c-8177-583104d01305", + "name": "my-new-kc-clientscope" + }] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 1) + self.assertEqual(mock_create_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0) + self.assertEqual(mock_update_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_clientscope(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'absent', + 'name': 'my-new-kc-clientscope' + } + return_value_get_clientscope_by_name = [{ + "attributes": {}, + "id": "73fec1d2-f032-410c-8177-583104d01305", + "name": "my-new-kc-clientscope" + }] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 1) + self.assertEqual(mock_create_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0) + self.assertEqual(mock_update_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 1) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_clientscope_idempotency(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'absent', + 'name': 'my-new-kc-clientscope' + } + return_value_get_clientscope_by_name = [None] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 1) + self.assertEqual(mock_create_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0) + self.assertEqual(mock_update_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_create_clientscope_with_protocolmappers(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'present', + 'name': 'my-new-kc-clientscope', + 'protocolMappers': [ + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'true', + 'id.token.claim': 'true', + 'access.token.claim': 'true', + 'userinfo.token.claim': 'true', + 'claim.name': 'protocol1', + }, + 'name': 'protocol1', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'false', + 'id.token.claim': 'false', + 'access.token.claim': 'false', + 'userinfo.token.claim': 'false', + 'claim.name': 'protocol2', + }, + 'name': 'protocol2', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'true', + 'id.token.claim': 'false', + 'access.token.claim': 'true', + 'userinfo.token.claim': 'false', + 'claim.name': 'protocol3', + }, + 'name': 'protocol3', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + ] + } + return_value_get_clientscope_by_name = [ + None, + { + "attributes": {}, + "id": "890ec72e-fe1d-4308-9f27-485ef7eaa182", + "name": "my-new-kc-clientscope", + "protocolMappers": [ + { + "config": { + "access.token.claim": "false", + "claim.name": "protocol2", + "full.path": "false", + "id.token.claim": "false", + "userinfo.token.claim": "false" + }, + "consentRequired": "false", + "id": "a7f19adb-cc58-41b1-94ce-782dc255139b", + "name": "protocol2", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "protocol3", + "full.path": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + }, + "consentRequired": "false", + "id": "2103a559-185a-40f4-84ae-9ab311d5b812", + "name": "protocol3", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "protocol1", + "full.path": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": "false", + "id": "bbf6390f-e95f-4c20-882b-9dad328363b9", + "name": "protocol1", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }] + }] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 2) + self.assertEqual(mock_create_clientscope.call_count, 1) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0) + self.assertEqual(mock_update_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_update_clientscope_with_protocolmappers(self): + """Add a new authentication flow from copy of an other flow""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'realm': 'realm-name', + 'state': 'present', + 'name': 'my-new-kc-clientscope', + 'protocolMappers': [ + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'false', + 'id.token.claim': 'false', + 'access.token.claim': 'false', + 'userinfo.token.claim': 'false', + 'claim.name': 'protocol1_updated', + }, + 'name': 'protocol1', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'true', + 'id.token.claim': 'false', + 'access.token.claim': 'false', + 'userinfo.token.claim': 'false', + 'claim.name': 'protocol2_updated', + }, + 'name': 'protocol2', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + { + 'protocol': 'openid-connect', + 'config': { + 'full.path': 'true', + 'id.token.claim': 'true', + 'access.token.claim': 'true', + 'userinfo.token.claim': 'true', + 'claim.name': 'protocol3_updated', + }, + 'name': 'protocol3', + 'protocolMapper': 'oidc-group-membership-mapper', + }, + ] + } + return_value_get_clientscope_by_name = [{ + "attributes": {}, + "id": "890ec72e-fe1d-4308-9f27-485ef7eaa182", + "name": "my-new-kc-clientscope", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "groups", + "full.path": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": "false", + "id": "e077007a-367a-444f-91ef-70277a1d868d", + "name": "groups", + "protocol": "saml", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "groups", + "full.path": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": "false", + "id": "06c518aa-c627-43cc-9a82-d8467b508d34", + "name": "groups", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "groups", + "full.path": "true", + "id.token.claim": "true", + "userinfo.token.claim": "true" + }, + "consentRequired": "false", + "id": "1d03c557-d97e-40f4-ac35-6cecd74ea70d", + "name": "groups", + "protocol": "wsfed", + "protocolMapper": "oidc-group-membership-mapper" + } + ] + }] + return_value_get_clientscope_by_clientscopeid = [{ + "attributes": {}, + "id": "2286032f-451e-44d5-8be6-e45aac7983a1", + "name": "my-new-kc-clientscope", + "protocolMappers": [ + { + "config": { + "access.token.claim": "true", + "claim.name": "protocol1_updated", + "full.path": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + }, + "consentRequired": "false", + "id": "a7f19adb-cc58-41b1-94ce-782dc255139b", + "name": "protocol2", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "true", + "claim.name": "protocol1_updated", + "full.path": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false" + }, + "consentRequired": "false", + "id": "2103a559-185a-40f4-84ae-9ab311d5b812", + "name": "protocol3", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + }, + { + "config": { + "access.token.claim": "false", + "claim.name": "protocol1_updated", + "full.path": "false", + "id.token.claim": "false", + "userinfo.token.claim": "false" + }, + "consentRequired": "false", + "id": "bbf6390f-e95f-4c20-882b-9dad328363b9", + "name": "protocol1", + "protocol": "openid-connect", + "protocolMapper": "oidc-group-membership-mapper" + } + ] + }] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name, + get_clientscope_by_clientscopeid=return_value_get_clientscope_by_clientscopeid) \ + as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, + mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, + mock_update_clientscope_protocolmappers, + mock_create_clientscope_protocolmapper, mock_delete_clientscope): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Verify number of call on each mock + self.assertEqual(mock_get_clientscope_by_name.call_count, 1) + self.assertEqual(mock_create_clientscope.call_count, 0) + self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 1) + self.assertEqual(mock_update_clientscope.call_count, 1) + self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 3) + self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 3) + self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0) + self.assertEqual(mock_delete_clientscope.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + +if __name__ == '__main__': + unittest.main()