diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index b11289a634..8521650f16 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -43,8 +43,14 @@ URL_REALM = "{url}/admin/realms/{realm}" URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" + URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" +URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" +URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" + URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" +URL_REALM_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/roles/{name}/composites" URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" @@ -632,10 +638,197 @@ class KeycloakAPI(object): try: return open_url(group_url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) - except Exception as e: self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) + def get_realm_roles(self, realm='master'): + """ Obtains role representations for roles in a realm + + :param realm: realm to be queried + :return: list of dicts of role representations + """ + rolelist_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of roles for realm %s: %s' + % (realm, str(e))) + + def get_realm_role(self, name, realm='master'): + """ Fetch a keycloak role from the provided realm using the role's name. + + If the role does not exist, None is returned. + :param name: Name of the role to fetch. + :param realm: Realm in which the role resides; default 'master'. + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=name) + try: + return json.loads(to_native(open_url(role_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 role %s in realm %s: %s' + % (name, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' + % (name, realm, str(e))) + + def create_realm_role(self, rolerep, realm='master'): + """ Create a Keycloak realm role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + return open_url(roles_url, method='POST', headers=self.restheaders, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create role %s in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def update_realm_role(self, rolerep, realm='master'): + """ Update an existing realm role. + + :param rolerep: A RoleRepresentation of the updated role. + :return HTTPResponse object on success + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=rolerep['name']) + try: + return open_url(role_url, method='PUT', headers=self.restheaders, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update role %s in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def delete_realm_role(self, name, realm='master'): + """ Delete a realm role. + + :param name: The name of the role. + :param realm: The realm in which this role resides, default "master". + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=name) + try: + return open_url(role_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete role %s in realm %s: %s' + % (name, realm, str(e))) + + def get_client_roles(self, clientid, realm='master'): + """ Obtains role representations for client roles in a specific client + + :param clientid: Client id to be queried + :param realm: Realm to be queried + :return: List of dicts of role representations + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + rolelist_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + + def get_client_role(self, name, clientid, realm='master'): + """ Fetch a keycloak client role from the provided realm using the role's name. + + :param name: Name of the role to fetch. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: Dict of role representation + If the role does not exist, None is returned. + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=name) + try: + return json.loads(to_native(open_url(role_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 role %s in client %s of realm %s: %s' + % (name, clientid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch role %s for client %s in realm %s: %s' + % (name, clientid, realm, str(e))) + + def create_client_role(self, rolerep, clientid, realm='master'): + """ Create a Keycloak client role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return open_url(roles_url, method='POST', headers=self.restheaders, + 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 update_client_role(self, rolerep, clientid, realm="master"): + """ Update an existing client role. + + :param rolerep: A RoleRepresentation of the updated role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=rolerep['name']) + try: + return open_url(role_url, method='PUT', headers=self.restheaders, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + 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))) + + def delete_client_role(self, name, clientid, realm="master"): + """ Delete a role. One of name or roleid must be provided. + + :param name: The name of the role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=name) + try: + return open_url(role_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete role %s for client %s in realm %s: %s' + % (name, clientid, realm, str(e))) + def get_authentication_flow_by_alias(self, alias, realm='master'): """ Get an authentication flow by it's alias diff --git a/plugins/modules/identity/keycloak/keycloak_role.py b/plugins/modules/identity/keycloak/keycloak_role.py new file mode 100644 index 0000000000..23ed7cfeed --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_role.py @@ -0,0 +1,363 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Adam Goossens +# 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_role + +short_description: Allows administration of Keycloak roles via Keycloak API + +version_added: 3.4.0 + +description: + - This module allows you to add, remove or modify Keycloak roles 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. + + +options: + state: + description: + - State of the role. + - On C(present), the role will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the role will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + required: true + description: + - Name of the role. + - This parameter is required. + + description: + type: str + description: + - The role description. + + realm: + type: str + description: + - The Keycloak realm under which this role resides. + default: 'master' + + client_id: + type: str + description: + - If the role is a client role, the client id under which it resides. + - If this parameter is absent, the role is considered a realm role. + + attributes: + type: dict + 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. + +extends_documentation_fragment: +- community.general.keycloak + + +author: + - Laurent Paumier (@laurpaum) +''' + +EXAMPLES = ''' +- name: Create a Keycloak realm role, authentication with credentials + community.general.keycloak_role: + name: my-new-kc-role + 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 realm role, authentication with token + community.general.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost + +- name: Create a Keycloak client role + community.general.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + client_id: MyClient + 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: Delete a Keycloak role + community.general.keycloak_role: + name: my-role-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: Create a keycloak role with some custom attributes + community.general.keycloak_role: + 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-role + 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: "Role myrole has been updated" + +proposed: + description: Role representation of proposed changes to role + returned: always + type: dict + sample: { + "description": "My updated test description" + } +existing: + description: Role representation of existing role + returned: always + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } +end_state: + description: Role representation of role after module execution (sample is truncated) + returned: always + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My updated client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + name=dict(type='str', required=True), + description=dict(type='str'), + realm=dict(type='str', default='master'), + client_id=dict(type='str'), + attributes=dict(type='dict'), + ) + + 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') + clientid = module.params.get('client_id') + name = module.params.get('name') + state = module.params.get('state') + + # attributes in Keycloak have their values returned as lists + # via the API. attributes is a dict, so we'll transparently convert + # the values to lists. + if module.params.get('attributes') is not None: + for key, val in module.params['attributes'].items(): + module.params['attributes'][key] = [val] if not isinstance(val, list) else val + + # convert module parameters to client representation parameters (if they belong in there) + role_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id', 'composites'] and + module.params.get(x) is not None] + + # does the role already exist? + if clientid is None: + before_role = kc.get_realm_role(name, realm) + else: + before_role = kc.get_client_role(name, clientid, realm) + + if before_role is None: + before_role = dict() + + # build a changeset + changeset = dict() + + for param in role_params: + 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 + + # prepare the new role + updated_role = before_role.copy() + updated_role.update(changeset) + + result['proposed'] = changeset + result['existing'] = before_role + + # if before_role is none, the role doesn't exist. + if before_role == dict(): + if state == 'absent': + # nothing to do. + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = dict() + result['msg'] = 'Role does not exist; doing nothing.' + module.exit_json(**result) + + # for 'present', create a new role. + result['changed'] = True + + if name is None: + module.fail_json(msg='name must be specified when creating a new role') + + if module._diff: + result['diff'] = dict(before='', after=updated_role) + + if module.check_mode: + module.exit_json(**result) + + # do it for real! + if clientid is None: + kc.create_realm_role(updated_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.create_client_role(updated_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + + result['end_state'] = after_role + + result['msg'] = 'Role {name} has been created'.format(name=name) + module.exit_json(**result) + + else: + if state == 'present': + # no changes + if updated_role == before_role: + result['changed'] = False + result['end_state'] = updated_role + result['msg'] = "No changes required to role {name}.".format(name=name) + module.exit_json(**result) + + # update the existing role + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after=updated_role) + + if module.check_mode: + module.exit_json(**result) + + # do the update + if clientid is None: + kc.update_realm_role(updated_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.update_client_role(updated_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + + result['end_state'] = after_role + + result['msg'] = "Role {name} has been updated".format(name=name) + module.exit_json(**result) + + elif state == 'absent': + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after='') + + if module.check_mode: + module.exit_json(**result) + + # delete for real + if clientid is None: + kc.delete_realm_role(name, realm) + else: + kc.delete_client_role(name, clientid, realm) + + result['end_state'] = dict() + + result['msg'] = "Role {name} has been deleted".format(name=name) + module.exit_json(**result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py new file mode 120000 index 0000000000..48554b3a5f --- /dev/null +++ b/plugins/modules/keycloak_role.py @@ -0,0 +1 @@ +./identity/keycloak/keycloak_role.py \ No newline at end of file diff --git a/tests/integration/targets/keycloak_role/aliases b/tests/integration/targets/keycloak_role/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/keycloak_role/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/keycloak_role/tasks/main.yml b/tests/integration/targets/keycloak_role/tasks/main.yml new file mode 100644 index 0000000000..683cfc8677 --- /dev/null +++ b/tests/integration/targets/keycloak_role/tasks/main.yml @@ -0,0 +1,246 @@ +--- +- name: Create realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + +- name: Create client + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + state: present + register: client + +- name: Create new realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ role }}" + description: "{{ description_1 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role created + assert: + that: + - result is changed + - result.existing == {} + - result.end_state.name == "{{ role }}" + - result.end_state.containerId == "{{ realm }}" + +- name: Create existing realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ role }}" + description: "{{ description_1 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role unchanged + assert: + that: + - result is not changed + +- name: Update realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ role }}" + description: "{{ description_2 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role updated + assert: + that: + - result is changed + - result.existing.description == "{{ description_1 }}" + - result.end_state.description == "{{ description_2 }}" + +- name: Delete existing realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ role }}" + 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 + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ role }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert realm role unchanged + assert: + that: + - result is not changed + - result.end_state == {} + +- name: Create new client role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + name: "{{ role }}" + description: "{{ description_1 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role created + assert: + that: + - result is changed + - result.existing == {} + - result.end_state.name == "{{ role }}" + - result.end_state.containerId == "{{ client.end_state.id }}" + +- name: Create existing client role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + name: "{{ role }}" + description: "{{ description_1 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role unchanged + assert: + that: + - result is not changed + +- name: Update client role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + name: "{{ role }}" + description: "{{ description_2 }}" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role updated + assert: + that: + - result is changed + - result.existing.description == "{{ description_1 }}" + - result.end_state.description == "{{ description_2 }}" + +- name: Delete existing client role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + name: "{{ role }}" + 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 + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_id }}" + name: "{{ role }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert client role unchanged + assert: + that: + - result is not changed + - result.end_state == {} diff --git a/tests/integration/targets/keycloak_role/vars/main.yml b/tests/integration/targets/keycloak_role/vars/main.yml new file mode 100644 index 0000000000..0a725dc4a6 --- /dev/null +++ b/tests/integration/targets/keycloak_role/vars/main.yml @@ -0,0 +1,10 @@ +--- +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm +client_id: myclient +role: myrole +description_1: desc 1 +description_2: desc 2 diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_role.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_role.py new file mode 100644 index 0000000000..cffae17807 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_role.py @@ -0,0 +1,326 @@ +# -*- 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_role + +from itertools import count + +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): + """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_role.KeycloakAPI + with patch.object(obj, 'get_realm_role', side_effect=get_realm_role) as mock_get_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 + + +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 TestKeycloakRealmRole(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmRole, self).setUp() + self.module = keycloak_role + + def test_create_when_absent(self): + """Add a new 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', + } + return_value_absent = [ + None, + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_value_created = [None] + changed = True + + set_module_args(module_args) + + # Run the module + + 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): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 2) + self.assertEqual(len(mock_create_realm_role.mock_calls), 1) + self.assertEqual(len(mock_update_realm_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_when_present_with_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', + 'name': 'role-name', + 'description': 'new-role-description', + } + return_value_present = [ + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + }, + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "new-role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_value_updated = [None] + changed = True + + 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) \ + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 2) + self.assertEqual(len(mock_create_realm_role.mock_calls), 0) + self.assertEqual(len(mock_update_realm_role.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_create_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', + } + return_value_present = [ + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + }, + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_value_updated = [None] + 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) \ + as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): + 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) + + # 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""" + + 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', + 'state': 'absent' + } + return_value_absent = [None] + return_value_deleted = [None] + changed = False + + set_module_args(module_args) + + # Run the module + + 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): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 1) + self.assertEqual(len(mock_delete_realm_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_delete_when_present(self): + """Remove a present 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', + 'state': 'absent' + } + return_value_absent = [ + { + "attributes": {}, + "clientRole": False, + "composite": False, + "containerId": "realm-name", + "description": "role-description", + "id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966", + "name": "role-name", + } + ] + return_value_deleted = [None] + changed = True + + set_module_args(module_args) + + # Run the module + + 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): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_role.mock_calls), 1) + self.assertEqual(len(mock_delete_realm_role.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()