From 9395df1c6f95e2b7075f72f62ccf8410e4a2cf60 Mon Sep 17 00:00:00 2001 From: Philippe Gauthier Date: Thu, 15 Jun 2023 00:57:30 -0400 Subject: [PATCH] Inspq keycloak role composites (#6469) * Add composites to keycloak_role module * Add composites support for realm role in keycloak module_utils * Clean f.write from keycloak_role module * keycloak_role support state for realm role composites * Add support for composites in client role for keycloak_role module * Add changelog fragment for keycloak role composites PR * Fix pep8 and validate-modules tests errors * Update changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py I will try it Co-authored-by: Felix Fontein * Fix test_keycloak_role assertion * Fix role composite compare before update in keycloak_role module * Fix realm problem with update_role_composites in keycloak.py module_utils * Add units tests for composites and client roles in keycloak_role module * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Felix Fontein * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Felix Fontein * Change try in is_struct_included and add unit tests for keycloak.py module_utils * Add integration tests for composites roles and fix bug with non master roles in keycloak_role module * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/modules/keycloak_role.py Co-authored-by: Felix Fontein * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Felix Fontein * Update plugins/module_utils/identity/keycloak/keycloak.py Co-authored-by: Felix Fontein * is_struct_included refactor --------- Co-authored-by: Felix Fontein --- ...d-composites-support-for-keycloak-role.yml | 2 + .../identity/keycloak/keycloak.py | 148 ++++++- plugins/modules/keycloak_role.py | 77 +++- .../targets/keycloak_role/README.md | 20 + .../targets/keycloak_role/tasks/main.yml | 233 +++++++++++ .../targets/keycloak_role/vars/main.yml | 27 ++ .../plugins/modules/test_keycloak_role.py | 370 +++++++++++++++++- 7 files changed, 860 insertions(+), 17 deletions(-) create mode 100644 changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml create mode 100644 tests/integration/targets/keycloak_role/README.md diff --git a/changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml b/changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml new file mode 100644 index 0000000000..ae883ec2b7 --- /dev/null +++ b/changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_role - add composite roles support for realm and client roles (https://github.com/ansible-collections/community.general/pull/6469). \ No newline at end of file diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index a08e8613e4..b1b76845f5 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -1680,6 +1680,9 @@ class KeycloakAPI(object): """ roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(rolerep), validate_certs=self.validate_certs) except Exception as e: @@ -1694,12 +1697,124 @@ class KeycloakAPI(object): """ role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name'])) try: - return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep["composites"] + role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + if composites is not None: + self.update_role_composites(rolerep=rolerep, composites=composites, realm=realm) + return role_response except Exception as e: self.module.fail_json(msg='Could not update role %s in realm %s: %s' % (rolerep['name'], realm, str(e))) + def get_role_composites(self, rolerep, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"])) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"])) + # Get existing composites + return json.loads(to_native(open_url( + composite_url, + method='GET', + http_agent=self.http_agent, + headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg='Could not get role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def create_role_composites(self, rolerep, composites, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"])) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"])) + # Get existing composites + # create new composites + return open_url(composite_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(composites), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def delete_role_composites(self, rolerep, composites, clientid=None, realm='master'): + composite_url = '' + try: + if clientid is not None: + client = self.get_client_by_clientid(client_id=clientid, realm=realm) + cid = client['id'] + composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"])) + else: + composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"])) + # Get existing composites + # create new composites + return open_url(composite_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(composites), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create role %s composites in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def update_role_composites(self, rolerep, composites, clientid=None, realm='master'): + # Get existing composites + existing_composites = self.get_role_composites(rolerep=rolerep, clientid=clientid, realm=realm) + composites_to_be_created = [] + composites_to_be_deleted = [] + for composite in composites: + composite_found = False + existing_composite_client = None + for existing_composite in existing_composites: + if existing_composite["clientRole"]: + existing_composite_client = self.get_client_by_id(existing_composite["containerId"], realm=realm) + if ("client_id" in composite + and composite['client_id'] is not None + and existing_composite_client["clientId"] == composite["client_id"] + and composite["name"] == existing_composite["name"]): + composite_found = True + break + else: + if (("client_id" not in composite or composite['client_id'] is None) + and composite["name"] == existing_composite["name"]): + composite_found = True + break + if (not composite_found and ('state' not in composite or composite['state'] == 'present')): + if "client_id" in composite and composite['client_id'] is not None: + client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + for client_role in client_roles: + if client_role['name'] == composite['name']: + composites_to_be_created.append(client_role) + break + else: + realm_role = self.get_realm_role(name=composite["name"], realm=realm) + composites_to_be_created.append(realm_role) + elif composite_found and 'state' in composite and composite['state'] == 'absent': + if "client_id" in composite and composite['client_id'] is not None: + client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm) + for client_role in client_roles: + if client_role['name'] == composite['name']: + composites_to_be_deleted.append(client_role) + break + else: + realm_role = self.get_realm_role(name=composite["name"], realm=realm) + composites_to_be_deleted.append(realm_role) + + if len(composites_to_be_created) > 0: + # create new composites + self.create_role_composites(rolerep=rolerep, composites=composites_to_be_created, clientid=clientid, realm=realm) + if len(composites_to_be_deleted) > 0: + # delete new composites + self.delete_role_composites(rolerep=rolerep, composites=composites_to_be_deleted, clientid=clientid, realm=realm) + def delete_realm_role(self, name, realm='master'): """ Delete a realm role. @@ -1778,12 +1893,30 @@ class KeycloakAPI(object): % (clientid, realm)) roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: + if "composites" in rolerep: + keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"]) + rolerep["composites"] = keycloak_compatible_composites return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, data=json.dumps(rolerep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s' % (rolerep['name'], clientid, realm, str(e))) + def convert_role_composites(self, composites): + keycloak_compatible_composites = { + 'client': {}, + 'realm': [] + } + for composite in composites: + if 'state' not in composite or composite['state'] == 'present': + if "client_id" in composite and composite["client_id"] is not None: + if composite["client_id"] not in keycloak_compatible_composites["client"]: + keycloak_compatible_composites["client"][composite["client_id"]] = [] + keycloak_compatible_composites["client"][composite["client_id"]].append(composite["name"]) + else: + keycloak_compatible_composites["realm"].append(composite["name"]) + return keycloak_compatible_composites + def update_client_role(self, rolerep, clientid, realm="master"): """ Update an existing client role. @@ -1798,8 +1931,15 @@ class KeycloakAPI(object): % (clientid, realm)) role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'])) try: - return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, - data=json.dumps(rolerep), validate_certs=self.validate_certs) + composites = None + if "composites" in rolerep: + composites = copy.deepcopy(rolerep["composites"]) + del rolerep['composites'] + update_role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + if composites is not None: + self.update_role_composites(rolerep=rolerep, clientid=clientid, composites=composites, realm=realm) + return update_role_response except Exception as e: self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s' % (rolerep['name'], clientid, realm, str(e))) diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py index bbec5f5919..46758a7621 100644 --- a/plugins/modules/keycloak_role.py +++ b/plugins/modules/keycloak_role.py @@ -77,6 +77,42 @@ options: description: - A dict of key/value pairs to set as custom attributes for the role. - Values may be single values (e.g. a string) or a list of strings. + composite: + description: + - If V(true), the role is a composition of other realm and/or client role. + default: false + type: bool + version_added: 7.1.0 + composites: + description: + - List of roles to include to the composite realm role. + - If the composite role is a client role, the C(clientId) (not ID of the client) must be specified. + default: [] + type: list + elements: dict + version_added: 7.1.0 + suboptions: + name: + description: + - Name of the role. This can be the name of a REALM role or a client role. + type: str + required: true + client_id: + description: + - Client ID if the role is a client role. Do not include this option for a REALM role. + - Use the client ID you can see in the Keycloak console, not the technical ID of the client. + type: str + required: false + aliases: + - clientId + state: + description: + - Create the composite if present, remove it if absent. + type: str + choices: + - present + - absent + default: present extends_documentation_fragment: - community.general.keycloak @@ -198,8 +234,9 @@ end_state: ''' from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, get_token, KeycloakError + keycloak_argument_spec, get_token, KeycloakError, is_struct_included from ansible.module_utils.basic import AnsibleModule +import copy def main(): @@ -210,6 +247,12 @@ def main(): """ argument_spec = keycloak_argument_spec() + composites_spec = dict( + name=dict(type='str', required=True), + client_id=dict(type='str', aliases=['clientId'], required=False), + state=dict(type='str', default='present', choices=['present', 'absent']) + ) + meta_args = dict( state=dict(type='str', default='present', choices=['present', 'absent']), name=dict(type='str', required=True), @@ -217,6 +260,8 @@ def main(): realm=dict(type='str', default='master'), client_id=dict(type='str'), attributes=dict(type='dict'), + composites=dict(type='list', default=[], options=composites_spec, elements='dict'), + composite=dict(type='bool', default=False), ) argument_spec.update(meta_args) @@ -250,7 +295,7 @@ def main(): # Filter and map the parameters names that apply to the role role_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id', 'composites'] and + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id'] and module.params.get(x) is not None] # See if it already exists in Keycloak @@ -269,10 +314,10 @@ def main(): new_param_value = module.params.get(param) old_value = before_role[param] if param in before_role else None if new_param_value != old_value: - changeset[camel(param)] = new_param_value + changeset[camel(param)] = copy.deepcopy(new_param_value) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) - desired_role = before_role.copy() + desired_role = copy.deepcopy(before_role) desired_role.update(changeset) result['proposed'] = changeset @@ -309,6 +354,9 @@ def main(): kc.create_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) + result['end_state'] = after_role result['msg'] = 'Role {name} has been created'.format(name=name) @@ -316,10 +364,25 @@ def main(): else: if state == 'present': + compare_exclude = [] + if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0: + composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm) + before_role['composites'] = [] + for composite in composites: + before_composite = {} + if composite['clientRole']: + composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm) + before_composite['client_id'] = composite_client['clientId'] + else: + before_composite['client_id'] = None + before_composite['name'] = composite['name'] + before_composite['state'] = 'present' + before_role['composites'].append(before_composite) + else: + compare_exclude.append('composites') # Process an update - # no changes - if desired_role == before_role: + if is_struct_included(desired_role, before_role, exclude=compare_exclude): result['changed'] = False result['end_state'] = desired_role result['msg'] = "No changes required to role {name}.".format(name=name) @@ -341,6 +404,8 @@ def main(): else: kc.update_client_role(desired_role, clientid, realm) after_role = kc.get_client_role(name, clientid, realm) + if after_role['composite']: + after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm) result['end_state'] = after_role diff --git a/tests/integration/targets/keycloak_role/README.md b/tests/integration/targets/keycloak_role/README.md new file mode 100644 index 0000000000..ccb4c8ffaa --- /dev/null +++ b/tests/integration/targets/keycloak_role/README.md @@ -0,0 +1,20 @@ + +# Running keycloak_user module integration test + +To run Keycloak user module's integration test, start a keycloak server using Docker or Podman: + + podman|docker run -d --rm --name mykeycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:latest start-dev --http-relative-path /auth + +Source Ansible env-setup from ansible github repository + +Run integration tests: + + ansible-test integration keycloak_role --python 3.10 --allow-unsupported + +Cleanup: + + podman|docker stop mykeycloak diff --git a/tests/integration/targets/keycloak_role/tasks/main.yml b/tests/integration/targets/keycloak_role/tasks/main.yml index 61b62629a4..c649b86808 100644 --- a/tests/integration/targets/keycloak_role/tasks/main.yml +++ b/tests/integration/targets/keycloak_role/tasks/main.yml @@ -248,3 +248,236 @@ that: - result is not changed - result.end_state == {} + +- name: Create realm role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role is created with composites + assert: + that: + - result is changed + - result.end_state.composites | length == 3 + +- name: Change realm role with composites no change + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role with composites have not changed + assert: + that: + - result is not changed + - result.end_state.composites | length == 3 + +- name: Remove composite from realm role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites_with_absent }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert composite was removed from realm role with composites + assert: + that: + - result is changed + - result.end_state.composites | length == 2 + +- name: Delete realm role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ keycloak_role_name }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role deleted + assert: + that: + - result is changed + - result.end_state == {} + +- name: Delete absent realm role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ keycloak_role_name }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert not changed and realm role absent + assert: + that: + - result is not changed + - result.end_state == {} + +- name: Create client role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role is created with composites + assert: + that: + - result is changed + - result.end_state.composites | length == 3 + +- name: Change client role with composites no change + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role with composites have not changed + assert: + that: + - result is not changed + - result.end_state.composites | length == 3 + +- name: Remove composite from client role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ keycloak_role_name }}" + client_id: "{{ client_id }}" + realm: "{{ realm }}" + description: "{{ keycloak_role_description }}" + composite: "{{ keycloak_role_composite }}" + composites: "{{ keycloak_role_composites_with_absent }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert composite was removed from client role with composites + assert: + that: + - result is changed + - result.end_state.composites | length == 2 + +- name: Delete client role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ keycloak_role_name }}" + client_id: "{{ client_id }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role deleted + assert: + that: + - result is changed + - result.end_state == {} + +- name: Delete absent client role with composites + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ keycloak_role_name }}" + client_id: "{{ client_id }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert not changed and client role absent + assert: + that: + - result is not changed + - result.end_state == {} \ No newline at end of file diff --git a/tests/integration/targets/keycloak_role/vars/main.yml b/tests/integration/targets/keycloak_role/vars/main.yml index b003311e0f..0af55dfc5c 100644 --- a/tests/integration/targets/keycloak_role/vars/main.yml +++ b/tests/integration/targets/keycloak_role/vars/main.yml @@ -12,3 +12,30 @@ client_id: myclient role: myrole description_1: desc 1 description_2: desc 2 + +keycloak_role_name: test +keycloak_role_description: test +keycloak_role_composite: true +keycloak_role_composites: + - name: view-clients + client_id: "realm-management" + state: present + - name: query-clients + client_id: "realm-management" + state: present + - name: offline_access + state: present +keycloak_client_id: test-client +keycloak_client_name: test-client +keycloak_client_description: This is a client for testing purpose +role_state: present + +keycloak_role_composites_with_absent: + - name: view-clients + client_id: "realm-management" + state: present + - name: query-clients + client_id: "realm-management" + state: present + - name: offline_access + state: absent \ No newline at end of file diff --git a/tests/unit/plugins/modules/test_keycloak_role.py b/tests/unit/plugins/modules/test_keycloak_role.py index c48c9771a5..cc2f6e716b 100644 --- a/tests/unit/plugins/modules/test_keycloak_role.py +++ b/tests/unit/plugins/modules/test_keycloak_role.py @@ -21,7 +21,9 @@ from ansible.module_utils.six import StringIO @contextmanager -def patch_keycloak_api(get_realm_role, create_realm_role=None, update_realm_role=None, delete_realm_role=None): +def patch_keycloak_api(get_realm_role=None, create_realm_role=None, update_realm_role=None, delete_realm_role=None, + get_client_role=None, create_client_role=None, update_client_role=None, delete_client_role=None, + get_client_by_id=None, get_role_composites=None): """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server Patches the `login` and `_post_json` methods @@ -41,7 +43,15 @@ def patch_keycloak_api(get_realm_role, create_realm_role=None, update_realm_role with patch.object(obj, 'create_realm_role', side_effect=create_realm_role) as mock_create_realm_role: with patch.object(obj, 'update_realm_role', side_effect=update_realm_role) as mock_update_realm_role: with patch.object(obj, 'delete_realm_role', side_effect=delete_realm_role) as mock_delete_realm_role: - yield mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role + with patch.object(obj, 'get_client_role', side_effect=get_client_role) as mock_get_client_role: + with patch.object(obj, 'create_client_role', side_effect=create_client_role) as mock_create_client_role: + with patch.object(obj, 'update_client_role', side_effect=update_client_role) as mock_update_client_role: + with patch.object(obj, 'delete_client_role', side_effect=delete_client_role) as mock_delete_client_role: + with patch.object(obj, 'get_client_by_id', side_effect=get_client_by_id) as mock_get_client_by_id: + with patch.object(obj, 'get_role_composites', side_effect=get_role_composites) as mock_get_role_composites: + yield mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, \ + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, \ + mock_get_client_by_id, mock_get_role_composites def get_response(object_with_future_response, method, get_id_call_count): @@ -125,7 +135,9 @@ class TestKeycloakRealmRole(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_realm_role=return_value_absent, create_realm_role=return_value_created) \ - as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -179,7 +191,9 @@ class TestKeycloakRealmRole(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \ - as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -233,7 +247,9 @@ class TestKeycloakRealmRole(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \ - as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -244,6 +260,140 @@ class TestKeycloakRealmRole(ModuleTestCase): # Verify that the module's changed status matches what is expected self.assertIs(exec_info.exception.args[0]['changed'], changed) + def test_create_with_composites_when_present_no_change(self): + """Update without change a realm role""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'name': 'role-name', + 'description': 'role-description', + 'composite': True, + 'composites': [ + { + 'client_id': 'client_1', + 'name': 'client-role1' + }, + { + 'name': 'realm-role-1' + } + ] + + } + return_value_present = [ + { + "attributes": {}, + "clientRole": False, + "composite": True, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + }, + { + "attributes": {}, + "clientRole": False, + "composite": True, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_value_updated = [None] + return_get_role_composites = [ + [ + { + 'clientRole': True, + 'containerId': 'c4367fac-f427-11ed-8e2f-aff070d20f0e', + 'name': 'client-role1' + }, + { + 'clientRole': False, + 'containerId': 'realm-name', + 'name': 'realm-role-1' + } + ] + ] + return_get_client_by_client_id = [ + { + "id": "de152444-f126-4a7a-8273-4ee1544133ad", + "clientId": "client_1", + "name": "client_1", + "description": "client_1", + "surrogateAuthRequired": False, + "enabled": True, + "alwaysDisplayInConsole": False, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "http://localhost:8080/*", + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": False, + "consentRequired": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "serviceAccountsEnabled": False, + "publicClient": False, + "frontchannelLogout": False, + "protocol": "openid-connect", + "attributes": { + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": True, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated, + get_client_by_id=return_get_client_by_client_id, + get_role_composites=return_get_role_composites) \ + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 1) + self.assertEqual(len(mock_create_realm_role.mock_calls), 0) + self.assertEqual(len(mock_update_realm_role.mock_calls), 0) + self.assertEqual(len(mock_get_client_by_client_id.mock_calls), 1) + self.assertEqual(len(mock_get_role_composites.mock_calls), 1) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + def test_delete_when_absent(self): """Remove an absent realm role""" @@ -268,7 +418,9 @@ class TestKeycloakRealmRole(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \ - as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -312,7 +464,9 @@ class TestKeycloakRealmRole(ModuleTestCase): with mock_good_connection(): with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \ - as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): with self.assertRaises(AnsibleExitJson) as exec_info: self.module.main() @@ -323,5 +477,207 @@ class TestKeycloakRealmRole(ModuleTestCase): self.assertIs(exec_info.exception.args[0]['changed'], changed) +class TestKeycloakClientRole(ModuleTestCase): + def setUp(self): + super(TestKeycloakClientRole, self).setUp() + self.module = keycloak_role + + def test_create_client_role_with_composites_when_absent(self): + """Update with change a realm role""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'client_id': 'client-name', + 'name': 'role-name', + 'description': 'role-description', + 'composite': True, + 'composites': [ + { + 'client_id': 'client_1', + 'name': 'client-role1' + }, + { + 'name': 'realm-role-1' + } + ] + } + return_get_client_role = [ + None, + { + "attributes": {}, + "clientRole": True, + "composite": True, + "composites": [ + { + 'client': { + 'client1': ['client-role1'] + } + }, + { + 'realm': ['realm-role-1'] + } + ], + "containerId": "9ae25ec2-f40a-11ed-9261-b3bacf720f69", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_role=return_get_client_role) \ + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 0) + self.assertEqual(len(mock_create_realm_role.mock_calls), 0) + self.assertEqual(len(mock_update_realm_role.mock_calls), 0) + self.assertEqual(len(mock_get_client_role.mock_calls), 2) + self.assertEqual(len(mock_create_client_role.mock_calls), 1) + self.assertEqual(len(mock_update_client_role.mock_calls), 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_create_client_role_with_composites_when_present_no_change(self): + """Update with change a realm role""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'client_id': 'client-name', + 'name': 'role-name', + 'description': 'role-description', + 'composite': True, + 'composites': [ + { + 'client_id': 'client_1', + 'name': 'client-role1' + }, + { + 'name': 'realm-role-1' + } + ] + } + return_get_client_role = [ + { + "attributes": {}, + "clientRole": True, + "composite": True, + "containerId": "9ae25ec2-f40a-11ed-9261-b3bacf720f69", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_get_role_composites = [ + [ + { + 'clientRole': True, + 'containerId': 'c4367fac-f427-11ed-8e2f-aff070d20f0e', + 'name': 'client-role1' + }, + { + 'clientRole': False, + 'containerId': 'realm-name', + 'name': 'realm-role-1' + } + ] + ] + return_get_client_by_client_id = [ + { + "id": "de152444-f126-4a7a-8273-4ee1544133ad", + "clientId": "client_1", + "name": "client_1", + "description": "client_1", + "surrogateAuthRequired": False, + "enabled": True, + "alwaysDisplayInConsole": False, + "clientAuthenticatorType": "client-secret", + "redirectUris": [ + "http://localhost:8080/*", + ], + "webOrigins": [ + "*" + ], + "notBefore": 0, + "bearerOnly": False, + "consentRequired": False, + "standardFlowEnabled": True, + "implicitFlowEnabled": False, + "directAccessGrantsEnabled": False, + "serviceAccountsEnabled": False, + "publicClient": False, + "frontchannelLogout": False, + "protocol": "openid-connect", + "attributes": { + "backchannel.logout.session.required": "true", + "backchannel.logout.revoke.offline.tokens": "false" + }, + "authenticationFlowBindingOverrides": {}, + "fullScopeAllowed": True, + "nodeReRegistrationTimeout": -1, + "defaultClientScopes": [ + "web-origins", + "acr", + "profile", + "roles", + "email" + ], + "optionalClientScopes": [ + "address", + "phone", + "offline_access", + "microprofile-jwt" + ] + } + ] + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_role=return_get_client_role, get_client_by_id=return_get_client_by_client_id, + get_role_composites=return_get_role_composites) \ + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, + mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, + mock_get_client_by_client_id, mock_get_role_composites): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 0) + self.assertEqual(len(mock_create_realm_role.mock_calls), 0) + self.assertEqual(len(mock_update_realm_role.mock_calls), 0) + self.assertEqual(len(mock_get_client_role.mock_calls), 1) + self.assertEqual(len(mock_create_client_role.mock_calls), 0) + self.assertEqual(len(mock_update_client_role.mock_calls), 0) + self.assertEqual(len(mock_get_client_by_client_id.mock_calls), 1) + self.assertEqual(len(mock_get_role_composites.mock_calls), 1) + + # 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()