From bdafa3185128b099748456d35704b60025f30b23 Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Fri, 30 Jul 2021 18:29:20 +0000 Subject: [PATCH] Keycloak: add client_rolemapping management (#2941) (#3117) * Add Keycloak kc_client_rolemapping module * Fix documentation * Add unit tests for keycloak_client_rolemapping Keycloak module * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Fix documentation * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Minor fix * Add check mode * Refactoring: rename function from get_client_roles to get_client_roles_by_id * BOTMETA.yml: keycloak_client_rolemapping - add myself as maintainer * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_client_rolemapping.py Co-authored-by: Felix Fontein (cherry picked from commit 43fe26d83cd405786ed6d000ecf278f3bb6a76c4) Co-authored-by: Gaetan2907 <48204380+Gaetan2907@users.noreply.github.com> --- .github/BOTMETA.yml | 2 + .../identity/keycloak/keycloak.py | 123 +++- .../keycloak/keycloak_client_rolemapping.py | 347 +++++++++++ .../modules/keycloak_client_rolemapping.py | 1 + .../test_keycloak_client_rolemapping.py | 572 ++++++++++++++++++ 5 files changed, 1043 insertions(+), 2 deletions(-) create mode 100644 plugins/modules/identity/keycloak/keycloak_client_rolemapping.py create mode 120000 plugins/modules/keycloak_client_rolemapping.py create mode 100644 tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 859d88bb84..4912a03ba4 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -502,6 +502,8 @@ files: maintainers: elfelip Gaetan2907 $modules/identity/keycloak/keycloak_clientscope.py: maintainers: Gaetan2907 + $modules/identity/keycloak/keycloak_client_rolemapping.py: + maintainers: Gaetan2907 $modules/identity/keycloak/keycloak_group.py: maintainers: adamgoossens $modules/identity/keycloak/keycloak_realm.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 75ef2bba02..c782e3690c 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -62,6 +62,10 @@ 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_CLIENT_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}" +URL_CLIENT_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available" +URL_CLIENT_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" + 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" @@ -376,8 +380,8 @@ class KeycloakAPI(object): def create_client(self, clientrep, realm="master"): """ Create a client in keycloak - :param clientrep: Client representation of client to be created. Must at least contain field clientId - :param realm: realm for client to be created + :param clientrep: Client representation of client to be created. Must at least contain field clientId. + :param realm: realm for client to be created. :return: HTTPResponse object on success """ client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) @@ -405,6 +409,121 @@ class KeycloakAPI(object): self.module.fail_json(msg='Could not delete client %s in realm %s: %s' % (id, realm, str(e))) + def get_client_roles_by_id(self, cid, realm="master"): + """ Fetch the roles of the a client on the Keycloak server. + + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for client %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_client_role_by_name(self, gid, cid, name, realm="master"): + """ Get the role ID of a client. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param name: Name of the role. + :param realm: Realm from which to obtain the rolemappings. + :return: The ID of the role, None if not found. + """ + rolemappings = self.get_client_roles_by_id(cid, realm=realm) + for role in rolemappings: + if name == role['name']: + return role['id'] + return None + + def get_client_rolemapping_by_id(self, gid, cid, rid, realm='master'): + """ Obtain client representation by id + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + return None + + def get_client_available_rolemappings(self, gid, cid, realm="master"): + """ Fetch the available role of a client in a specified goup on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_client_composite_rolemappings(self, gid, cid, realm="master"): + """ Fetch the composite role of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): + """ Fetch the composite role of a client in a specified goup on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + open_url(available_rolemappings_url, method="POST", headers=self.restheaders, data=json.dumps(role_rep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def delete_group_rolemapping(self, gid, cid, role_rep, realm="master"): + """ Delete the rolemapping of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + open_url(available_rolemappings_url, method="DELETE", headers=self.restheaders, validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not delete available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + def get_client_templates(self, realm='master'): """ Obtains client template representations for client templates in a realm diff --git a/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py b/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py new file mode 100644 index 0000000000..e3d43d7919 --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py @@ -0,0 +1,347 @@ +#!/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_client_rolemapping + +short_description: Allows administration of Keycloak client_rolemapping with the Keycloak API +version_added: 3.5.0 + +description: + - This module allows you to add, remove or modify Keycloak client_rolemapping with 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_rolemapping, where possible provide the role ID to the module. This removes a lookup + to the API to translate the name into the role ID. + + +options: + state: + description: + - State of the client_rolemapping. + - On C(present), the client_rolemapping will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the client_rolemapping will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this role_representation resides. + default: 'master' + + group_name: + type: str + description: + - Name of the group to be mapped. + - This parameter is required (can be replaced by gid for less API call). + + gid: + type: str + description: + - Id of the group to be mapped. + - This parameter is not required for updating or deleting the rolemapping but + providing it will reduce the number of API calls required. + + client_id: + type: str + description: + - Name of the client to be mapped (different than I(cid)). + - This parameter is required (can be replaced by cid for less API call). + + cid: + type: str + description: + - Id of the client to be mapped. + - This parameter is not required for updating or deleting the rolemapping but + providing it will reduce the number of API calls required. + + roles: + description: + - Roles to be mapped to the group. + type: list + elements: dict + suboptions: + name: + type: str + description: + - Name of the role_representation. + - This parameter is required only when creating or updating the role_representation. + id: + type: str + description: + - The unique identifier for this role_representation. + - This parameter is not required for updating or deleting a role_representation but + providing it will reduce the number of API calls required. + +extends_documentation_fragment: +- community.general.keycloak + + +author: + - Gaƫtan Daubresse (@Gaetan2907) +''' + +EXAMPLES = ''' +- name: Map a client role to a group, authentication with credentials + community.general.keycloak_client_rolemappings: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a group, authentication with token + community.general.keycloak_client_rolemappings: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + state: present + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap client role from a group + community.general.keycloak_client_rolemappings: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + client_id: client1 + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Role role1 assigned to group group1." + +proposed: + description: role_representation representation of proposed changes to client_rolemapping. + returned: always + type: dict + sample: { + clientId: "test" + } +existing: + description: + - role_representation representation of existing role_representation. + - The sample is truncated. + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +end_state: + description: + - role_representation representation of role_representation after module execution. + - The 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 main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + roles_spec = dict( + name=dict(type='str'), + id=dict(type='str'), + ) + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + gid=dict(type='str'), + group_name=dict(type='str'), + cid=dict(type='str'), + client_id=dict(type='str'), + roles=dict(type='list', elements='dict', options=roles_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['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('cid') + client_id = module.params.get('client_id') + gid = module.params.get('gid') + group_name = module.params.get('group_name') + roles = module.params.get('roles') + + # Check the parameters + if cid is None and client_id is None: + module.fail_json(msg='Either the `client_id` or `cid` has to be specified.') + if gid is None and group_name is None: + module.fail_json(msg='Either the `group_name` or `gid` has to be specified.') + + # Get the potential missing parameters + if gid is None: + group_rep = kc.get_group_by_name(group_name, realm=realm) + if group_rep is not None: + gid = group_rep['id'] + else: + module.fail_json(msg='Could not fetch group %s:' % group_name) + if cid is None: + cid = kc.get_client_id(client_id, realm=realm) + if cid is None: + module.fail_json(msg='Could not fetch client %s:' % client_id) + if roles is None: + module.exit_json(msg="Nothing to do (no roles specified).") + else: + for role_index, role in enumerate(roles, start=0): + if role['name'] is None and role['id'] is None: + module.fail_json(msg='Either the `name` or `id` has to be specified on each role.') + # Fetch missing role_id + if role['id'] is None: + role_id = kc.get_client_role_by_name(gid, cid, role['name'], realm=realm) + if role_id is not None: + role['id'] = role_id + else: + module.fail_json(msg='Could not fetch role %s:' % (role['name'])) + # Fetch missing role_name + else: + role['name'] = kc.get_client_rolemapping_by_id(gid, cid, role['id'], realm=realm)['name'] + if role['name'] is None: + module.fail_json(msg='Could not fetch role %s' % (role['id'])) + + # Get effective client-level role mappings + available_roles_before = kc.get_client_available_rolemappings(gid, cid, realm=realm) + assigned_roles_before = kc.get_client_composite_rolemappings(gid, cid, realm=realm) + + result['existing'] = assigned_roles_before + result['proposed'] = roles + + update_roles = [] + for role_index, role in enumerate(roles, start=0): + # Fetch roles to assign if state present + if state == 'present': + for available_role in available_roles_before: + if role['name'] == available_role['name']: + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + # Fetch roles to remove if state absent + else: + for assigned_role in assigned_roles_before: + if role['name'] == assigned_role['name']: + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + + if len(update_roles): + if state == 'present': + # Assign roles + result['changed'] = True + if module._diff: + result['diff'] = dict(before=assigned_roles_before, after=update_roles) + if module.check_mode: + module.exit_json(**result) + kc.add_group_rolemapping(gid, cid, update_roles, realm=realm) + result['msg'] = 'Roles %s assigned to group %s.' % (update_roles, group_name) + assigned_roles_after = kc.get_client_composite_rolemappings(gid, cid, realm=realm) + result['end_state'] = assigned_roles_after + module.exit_json(**result) + else: + # Remove mapping of role + result['changed'] = True + if module._diff: + result['diff'] = dict(before=assigned_roles_before, after=update_roles) + if module.check_mode: + module.exit_json(**result) + kc.delete_group_rolemapping(gid, cid, update_roles, realm=realm) + result['msg'] = 'Roles %s removed from group %s.' % (update_roles, group_name) + assigned_roles_after = kc.get_client_composite_rolemappings(gid, cid, realm=realm) + result['end_state'] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result['changed'] = False + result['msg'] = 'Nothing to do, roles %s are correctly mapped with group %s.' % (roles, group_name) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_client_rolemapping.py b/plugins/modules/keycloak_client_rolemapping.py new file mode 120000 index 0000000000..02243ca68d --- /dev/null +++ b/plugins/modules/keycloak_client_rolemapping.py @@ -0,0 +1 @@ +identity/keycloak/keycloak_client_rolemapping.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py new file mode 100644 index 0000000000..8e753bc6d0 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py @@ -0,0 +1,572 @@ +# -*- 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_client_rolemapping + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_group_by_name=None, get_client_id=None, get_client_role_by_name=None, + get_client_rolemapping_by_id=None, get_client_available_rolemappings=None, + get_client_composite_rolemappings=None, add_group_rolemapping=None, + delete_group_rolemapping=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): + ... + """ + + obj = keycloak_client_rolemapping.KeycloakAPI + with patch.object(obj, 'get_group_by_name', + side_effect=get_group_by_name) as mock_get_group_by_name: + with patch.object(obj, 'get_client_id', + side_effect=get_client_id) as mock_get_client_id: + with patch.object(obj, 'get_client_role_by_name', + side_effect=get_client_role_by_name) as mock_get_client_role_by_name: + with patch.object(obj, 'get_client_rolemapping_by_id', + side_effect=get_client_rolemapping_by_id) as mock_get_client_rolemapping_by_id: + with patch.object(obj, 'get_client_available_rolemappings', + side_effect=get_client_available_rolemappings) as mock_get_client_available_rolemappings: + with patch.object(obj, 'get_client_composite_rolemappings', + side_effect=get_client_composite_rolemappings) as mock_get_client_composite_rolemappings: + with patch.object(obj, 'add_group_rolemapping', + side_effect=add_group_rolemapping) as mock_add_group_rolemapping: + with patch.object(obj, 'delete_group_rolemapping', + side_effect=delete_group_rolemapping) as mock_delete_group_rolemapping: + yield mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, \ + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, \ + mock_delete_group_rolemapping + + +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 TestKeycloakRealm(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealm, self).setUp() + self.module = keycloak_client_rolemapping + + def test_map_clientrole_to_group_with_name(self): + """Add a new realm""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'client_id': 'test_client', + 'group_name': 'test_group', + 'roles': [ + { + 'name': 'test_role1', + }, + { + 'name': 'test_role1', + }, + ], + } + return_value_get_group_by_name = [{ + "access": { + "manage": "true", + "manageMembership": "true", + "view": "true" + }, + "attributes": "{}", + "clientRoles": "{}", + "id": "92f2400e-0ecb-4185-8950-12dcef616c2b", + "name": "test_group", + "path": "/test_group", + "realmRoles": "[]", + "subGroups": "[]" + }] + return_value_get_client_id = "c0f8490c-b224-4737-a567-20223e4c1727" + return_value_get_client_role_by_name = "e91af074-cfd5-40ee-8ef5-ae0ae1ce69fe" + return_value_get_client_available_rolemappings = [[ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ]] + return_value_get_client_composite_rolemappings = [ + None, + [ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ] + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, + get_client_role_by_name=return_value_get_client_role_by_name, + get_client_available_rolemappings=return_value_get_client_available_rolemappings, + get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ + as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, + mock_delete_group_rolemapping): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_group_by_name.call_count, 1) + self.assertEqual(mock_get_client_id.call_count, 1) + self.assertEqual(mock_add_group_rolemapping.call_count, 1) + self.assertEqual(mock_get_client_rolemapping_by_id.call_count, 0) + self.assertEqual(mock_get_client_available_rolemappings.call_count, 1) + self.assertEqual(mock_get_client_composite_rolemappings.call_count, 2) + self.assertEqual(mock_delete_group_rolemapping.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_map_clientrole_to_group_with_name_idempotency(self): + """Add a new realm""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'client_id': 'test_client', + 'group_name': 'test_group', + 'roles': [ + { + 'name': 'test_role1', + }, + { + 'name': 'test_role1', + }, + ], + } + return_value_get_group_by_name = [{ + "access": { + "manage": "true", + "manageMembership": "true", + "view": "true" + }, + "attributes": "{}", + "clientRoles": "{}", + "id": "92f2400e-0ecb-4185-8950-12dcef616c2b", + "name": "test_group", + "path": "/test_group", + "realmRoles": "[]", + "subGroups": "[]" + }] + return_value_get_client_id = "c0f8490c-b224-4737-a567-20223e4c1727" + return_value_get_client_role_by_name = "e91af074-cfd5-40ee-8ef5-ae0ae1ce69fe" + return_value_get_client_available_rolemappings = [[]] + return_value_get_client_composite_rolemappings = [[ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ]] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, + get_client_role_by_name=return_value_get_client_role_by_name, + get_client_available_rolemappings=return_value_get_client_available_rolemappings, + get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ + as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, + mock_delete_group_rolemapping): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_group_by_name.call_count, 1) + self.assertEqual(mock_get_client_id.call_count, 1) + self.assertEqual(mock_add_group_rolemapping.call_count, 0) + self.assertEqual(mock_get_client_rolemapping_by_id.call_count, 0) + self.assertEqual(mock_get_client_available_rolemappings.call_count, 1) + self.assertEqual(mock_get_client_composite_rolemappings.call_count, 1) + self.assertEqual(mock_delete_group_rolemapping.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_map_clientrole_to_group_with_id(self): + """Add a new realm""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'cid': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'gid': '92f2400e-0ecb-4185-8950-12dcef616c2b', + 'roles': [ + { + 'name': 'test_role1', + }, + { + 'name': 'test_role1', + }, + ], + } + return_value_get_group_by_name = [{ + "access": { + "manage": "true", + "manageMembership": "true", + "view": "true" + }, + "attributes": "{}", + "clientRoles": "{}", + "id": "92f2400e-0ecb-4185-8950-12dcef616c2b", + "name": "test_group", + "path": "/test_group", + "realmRoles": "[]", + "subGroups": "[]" + }] + return_value_get_client_id = "c0f8490c-b224-4737-a567-20223e4c1727" + return_value_get_client_role_by_name = "e91af074-cfd5-40ee-8ef5-ae0ae1ce69fe" + return_value_get_client_available_rolemappings = [[ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ]] + return_value_get_client_composite_rolemappings = [ + None, + [ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ] + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, + get_client_role_by_name=return_value_get_client_role_by_name, + get_client_available_rolemappings=return_value_get_client_available_rolemappings, + get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ + as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, + mock_delete_group_rolemapping): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_group_by_name.call_count, 0) + self.assertEqual(mock_get_client_id.call_count, 0) + self.assertEqual(mock_add_group_rolemapping.call_count, 1) + self.assertEqual(mock_get_client_rolemapping_by_id.call_count, 0) + self.assertEqual(mock_get_client_available_rolemappings.call_count, 1) + self.assertEqual(mock_get_client_composite_rolemappings.call_count, 2) + self.assertEqual(mock_delete_group_rolemapping.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_remove_clientrole_from_group(self): + """Add a new realm""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'absent', + 'client_id': 'test_client', + 'group_name': 'test_group', + 'roles': [ + { + 'name': 'test_role1', + }, + { + 'name': 'test_role1', + }, + ], + } + return_value_get_group_by_name = [{ + "access": { + "manage": "true", + "manageMembership": "true", + "view": "true" + }, + "attributes": "{}", + "clientRoles": "{}", + "id": "92f2400e-0ecb-4185-8950-12dcef616c2b", + "name": "test_group", + "path": "/test_group", + "realmRoles": "[]", + "subGroups": "[]" + }] + return_value_get_client_id = "c0f8490c-b224-4737-a567-20223e4c1727" + return_value_get_client_role_by_name = "e91af074-cfd5-40ee-8ef5-ae0ae1ce69fe" + return_value_get_client_available_rolemappings = [[]] + return_value_get_client_composite_rolemappings = [ + [ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ], + [] + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, + get_client_role_by_name=return_value_get_client_role_by_name, + get_client_available_rolemappings=return_value_get_client_available_rolemappings, + get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ + as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, + mock_delete_group_rolemapping): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_group_by_name.call_count, 1) + self.assertEqual(mock_get_client_id.call_count, 1) + self.assertEqual(mock_add_group_rolemapping.call_count, 0) + self.assertEqual(mock_get_client_rolemapping_by_id.call_count, 0) + self.assertEqual(mock_get_client_available_rolemappings.call_count, 1) + self.assertEqual(mock_get_client_composite_rolemappings.call_count, 2) + self.assertEqual(mock_delete_group_rolemapping.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_remove_clientrole_from_group_idempotency(self): + """Add a new realm""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'absent', + 'client_id': 'test_client', + 'group_name': 'test_group', + 'roles': [ + { + 'name': 'test_role1', + }, + { + 'name': 'test_role1', + }, + ], + } + return_value_get_group_by_name = [{ + "access": { + "manage": "true", + "manageMembership": "true", + "view": "true" + }, + "attributes": "{}", + "clientRoles": "{}", + "id": "92f2400e-0ecb-4185-8950-12dcef616c2b", + "name": "test_group", + "path": "/test_group", + "realmRoles": "[]", + "subGroups": "[]" + }] + return_value_get_client_id = "c0f8490c-b224-4737-a567-20223e4c1727" + return_value_get_client_role_by_name = "e91af074-cfd5-40ee-8ef5-ae0ae1ce69fe" + return_value_get_client_available_rolemappings = [ + [ + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d", + "name": "test_role2" + }, + { + "clientRole": "true", + "composite": "false", + "containerId": "c0f8490c-b224-4737-a567-20223e4c1727", + "id": "00a2d9a9-924e-49fa-8cde-c539c010ef6e", + "name": "test_role1" + } + ] + ] + return_value_get_client_composite_rolemappings = [[]] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, + get_client_role_by_name=return_value_get_client_role_by_name, + get_client_available_rolemappings=return_value_get_client_available_rolemappings, + get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ + as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, + mock_get_client_rolemapping_by_id, mock_get_client_available_rolemappings, mock_get_client_composite_rolemappings, + mock_delete_group_rolemapping): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_group_by_name.call_count, 1) + self.assertEqual(mock_get_client_id.call_count, 1) + self.assertEqual(mock_add_group_rolemapping.call_count, 0) + self.assertEqual(mock_get_client_rolemapping_by_id.call_count, 0) + self.assertEqual(mock_get_client_available_rolemappings.call_count, 1) + self.assertEqual(mock_get_client_composite_rolemappings.call_count, 1) + self.assertEqual(mock_delete_group_rolemapping.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()