diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 926054ae99..4a0ddf7ab3 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -707,6 +707,8 @@ files: maintainers: fynncfchen $modules/keycloak_role.py: maintainers: laurpaum + $modules/keycloak_user.py: + maintainers: elfelip $modules/keycloak_user_federation.py: maintainers: laurpaum $modules/keycloak_user_rolemapping.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index c35ca43c2c..20314ede36 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -9,6 +9,7 @@ __metaclass__ = type import json import traceback +import copy from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import urlencode, quote @@ -64,6 +65,14 @@ URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" URL_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_USER_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings" +URL_USER_REALM_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients" +URL_USER_CLIENT_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client_id}" +URL_USER_GROUPS = "{url}/admin/realms/{realm}/users/{id}/groups" +URL_USER_GROUP = "{url}/admin/realms/{realm}/users/{id}/groups/{group_id}" + URL_CLIENT_SERVICE_ACCOUNT_USER = "{url}/admin/realms/{realm}/clients/{id}/service-account-user" URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}" URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" @@ -2382,3 +2391,245 @@ class KeycloakAPI(object): validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete scope %s for client %s in realm %s: %s' % (id, client_id, realm, str(e))) + + def get_user_by_id(self, user_id, realm='master'): + """ + Get a User by its ID. + :param user_id: ID of the user. + :param realm: Realm + :return: Representation of the user. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + userrep = json.load( + open_url( + user_url, + method='GET', + headers=self.restheaders)) + return userrep + except Exception as e: + self.module.fail_json(msg='Could not get user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def create_user(self, userrep, realm='master'): + """ + Create a new User. + :param userrep: Representation of the user to create + :param realm: Realm + :return: Representation of the user created. + """ + try: + if 'attributes' in userrep and isinstance(userrep['attributes'], list): + attributes = copy.deepcopy(userrep['attributes']) + userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + users_url = URL_USERS.format( + url=self.baseurl, + realm=realm) + open_url(users_url, + method='POST', + headers=self.restheaders, + data=json.dumps(userrep)) + created_user = self.get_user_by_username( + username=userrep['username'], + realm=realm) + return created_user + except Exception as e: + self.module.fail_json(msg='Could not create user %s in realm %s: %s' + % (userrep['username'], realm, str(e))) + + def convert_user_attributes_to_keycloak_dict(self, attributes): + keycloak_user_attributes_dict = {} + for attribute in attributes: + if ('state' not in attribute or attribute['state'] == 'present') and 'name' in attribute: + keycloak_user_attributes_dict[attribute['name']] = attribute['values'] if 'values' in attribute else [] + return keycloak_user_attributes_dict + + def convert_keycloak_user_attributes_dict_to_module_list(self, attributes): + module_attributes_list = [] + for key in attributes: + attr = {} + attr['name'] = key + attr['values'] = attributes[key] + module_attributes_list.append(attr) + return module_attributes_list + + def update_user(self, userrep, realm='master'): + """ + Update a User. + :param userrep: Representation of the user to update. This representation must include the ID of the user. + :param realm: Realm + :return: Representation of the updated user. + """ + try: + if 'attributes' in userrep and isinstance(userrep['attributes'], list): + attributes = copy.deepcopy(userrep['attributes']) + userrep['attributes'] = self.convert_user_attributes_to_keycloak_dict(attributes=attributes) + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=userrep["id"]) + open_url( + user_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(userrep)) + updated_user = self.get_user_by_id( + user_id=userrep['id'], + realm=realm) + return updated_user + except Exception as e: + self.module.fail_json(msg='Could not update user %s in realm %s: %s' + % (userrep['username'], realm, str(e))) + + def delete_user(self, user_id, realm='master'): + """ + Delete a User. + :param user_id: ID of the user to be deleted + :param realm: Realm + :return: HTTP response. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + return open_url( + user_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def get_user_groups(self, user_id, realm='master'): + """ + Get groups for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the client groups. + """ + try: + groups = [] + user_groups_url = URL_USER_GROUPS.format( + url=self.baseurl, + realm=realm, + id=user_id) + user_groups = json.load( + open_url( + user_groups_url, + method='GET', + headers=self.restheaders)) + for user_group in user_groups: + groups.append(user_group["name"]) + return groups + except Exception as e: + self.module.fail_json(msg='Could not get groups for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def add_user_in_group(self, user_id, group_id, realm='master'): + """ + Add a user to a group. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP Response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='PUT', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not add user %s in group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def remove_user_from_group(self, user_id, group_id, realm='master'): + """ + Remove a user from a group for a user. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not remove user %s from group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def update_user_groups_membership(self, userrep, groups, realm='master'): + """ + Update user's group membership + :param userrep: Representation of the user. This representation must include the ID. + :param realm: Realm + :return: True if group membership has been changed. False Otherwise. + """ + changed = False + try: + user_existing_groups = self.get_user_groups( + user_id=userrep['id'], + realm=realm) + groups_to_add_and_remove = self.extract_groups_to_add_to_and_remove_from_user(groups) + # If group membership need to be changed + if not is_struct_included(groups_to_add_and_remove['add'], user_existing_groups): + # Get available goups in the realm + realm_groups = self.get_groups(realm=realm) + for realm_group in realm_groups: + if "name" in realm_group and realm_group["name"] in groups_to_add_and_remove['add']: + self.add_user_in_group( + user_id=userrep["id"], + group_id=realm_group["id"], + realm=realm) + changed = True + elif "name" in realm_group and realm_group['name'] in groups_to_add_and_remove['remove']: + self.remove_user_from_group( + user_id=userrep['id'], + group_id=realm_group['id'], + realm=realm) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not update group membership for user %s in realm %s: %s' + % (userrep['id]'], realm, str(e))) + + def extract_groups_to_add_to_and_remove_from_user(self, groups): + groups_extract = {} + groups_to_add = [] + groups_to_remove = [] + if isinstance(groups, list) and len(groups) > 0: + for group in groups: + group_name = group['name'] if isinstance(group, dict) and 'name' in group else group + if isinstance(group, dict) and ('state' not in group or group['state'] == 'present'): + groups_to_add.append(group_name) + else: + groups_to_remove.append(group_name) + groups_extract['add'] = groups_to_add + groups_extract['remove'] = groups_to_remove + + return groups_extract + + def convert_user_group_list_of_str_to_list_of_dict(self, groups): + list_of_groups = [] + if isinstance(groups, list) and len(groups) > 0: + for group in groups: + if isinstance(group, str): + group_dict = {} + group_dict['name'] = group + list_of_groups.append(group_dict) + return list_of_groups diff --git a/plugins/modules/keycloak_user.py b/plugins/modules/keycloak_user.py new file mode 100644 index 0000000000..b6d2bfa747 --- /dev/null +++ b/plugins/modules/keycloak_user.py @@ -0,0 +1,542 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, INSPQ (@elfelip) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: keycloak_user +short_description: Create and configure a user in Keycloak +description: + - This module creates, removes, or updates Keycloak users. +version_added: 7.1.0 +options: + auth_username: + aliases: [] + realm: + description: + - The name of the realm in which is the client. + default: master + type: str + username: + description: + - Username for the user. + required: true + type: str + id: + description: + - ID of the user on the Keycloak server if known. + type: str + enabled: + description: + - Enabled user. + type: bool + email_verified: + description: + - Check the validity of user email. + default: false + type: bool + aliases: + - emailVerified + first_name: + description: + - The user's first name. + required: false + type: str + aliases: + - firstName + last_name: + description: + - The user's last name. + required: false + type: str + aliases: + - lastName + email: + description: + - User email. + required: false + type: str + federation_link: + description: + - Federation Link. + required: false + type: str + aliases: + - federationLink + service_account_client_id: + description: + - Description of the client Application. + required: false + type: str + aliases: + - serviceAccountClientId + client_consents: + description: + - Client Authenticator Type. + type: list + elements: dict + default: [] + aliases: + - clientConsents + suboptions: + client_id: + description: + - Client ID of the client role. Not the technical ID of the client. + type: str + required: true + aliases: + - clientId + roles: + description: + - List of client roles to assign to the user. + type: list + required: true + elements: str + groups: + description: + - List of groups for the user. + type: list + elements: dict + default: [] + suboptions: + name: + description: + - Name of the group. + type: str + state: + description: + - Control whether the user must be member of this group or not. + choices: [ "present", "absent" ] + default: present + type: str + credentials: + description: + - User credentials. + default: [] + type: list + elements: dict + suboptions: + type: + description: + - Credential type. + type: str + required: true + value: + description: + - Value of the credential. + type: str + required: true + temporary: + description: + - If C(true), the users are required to reset their credentials at next login. + type: bool + default: false + required_actions: + description: + - RequiredActions user Auth. + default: [] + type: list + elements: str + aliases: + - requiredActions + federated_identities: + description: + - List of IDPs of user. + default: [] + type: list + elements: str + aliases: + - federatedIdentities + attributes: + description: + - List of user attributes. + required: false + type: list + elements: dict + suboptions: + name: + description: + - Name of the attribute. + type: str + values: + description: + - Values for the attribute as list. + type: list + elements: str + state: + description: + - Control whether the attribute must exists or not. + choices: [ "present", "absent" ] + default: present + type: str + access: + description: + - list user access. + required: false + type: dict + disableable_credential_types: + description: + - list user Credential Type. + default: [] + type: list + elements: str + aliases: + - disableableCredentialTypes + origin: + description: + - user origin. + required: false + type: str + self: + description: + - user self administration. + required: false + type: str + state: + description: + - Control whether the user should exists or not. + choices: [ "present", "absent" ] + default: present + type: str + force: + description: + - If C(true), allows to remove user and recreate it. + type: bool + default: false +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: full +notes: + - The module does not modify the user ID of an existing user. +author: + - Philippe Gauthier (@elfelip) +''' + +EXAMPLES = ''' +- name: Create a user user1 + community.general.keycloak_user: + auth_keycloak_url: http://localhost:8080/auth + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + +- name: Re-create a User + community.general.keycloak_user: + auth_keycloak_url: http://localhost:8080/auth + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + +- name: Re-create a User + community.general.keycloak_user: + auth_keycloak_url: http://localhost:8080/auth + auth_username: admin + auth_password: password + realm: master + username: user1 + firstName: user1 + lastName: user1 + email: user1 + enabled: true + emailVerified: false + credentials: + - type: password + value: password + temporary: false + attributes: + - name: attr1 + values: + - value1 + state: present + - name: attr2 + values: + - value2 + state: absent + groups: + - name: group1 + state: present + state: present + force: true + +- name: Remove User + community.general.keycloak_user: + auth_keycloak_url: http://localhost:8080/auth + auth_username: admin + auth_password: password + realm: master + username: user1 + state: absent +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: User f18c709c-03d6-11ee-970b-c74bf2721112 created +proposed: + description: Representation of the proposed user. + returned: on success + type: dict +existing: + description: Representation of the existing user. + returned: on success + type: dict +end_state: + description: Representation of the user after module execution + returned: on success + type: dict +changed: + description: Return C(true) if the operation changed the user on the keycloak server, C(false) otherwise. + returned: always + type: bool +''' +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 +import copy + + +def main(): + argument_spec = keycloak_argument_spec() + argument_spec['auth_username']['aliases'] = [] + credential_spec = dict( + type=dict(type='str', required=True), + value=dict(type='str', required=True), + temporary=dict(type='bool', default=False) + ) + client_consents_spec = dict( + client_id=dict(type='str', required=True, aliases=['clientId']), + roles=dict(type='list', elements='str', required=True) + ) + attributes_spec = dict( + name=dict(type='str'), + values=dict(type='list', elements='str'), + state=dict(type='str', choices=['present', 'absent'], default='present') + ) + groups_spec = dict( + name=dict(type='str'), + state=dict(type='str', choices=['present', 'absent'], default='present') + ) + meta_args = dict( + realm=dict(type='str', default='master'), + self=dict(type='str'), + id=dict(type='str'), + username=dict(type='str', required=True), + first_name=dict(type='str', aliases=['firstName']), + last_name=dict(type='str', aliases=['lastName']), + email=dict(type='str'), + enabled=dict(type='bool'), + email_verified=dict(type='bool', default=False, aliases=['emailVerified']), + federation_link=dict(type='str', aliases=['federationLink']), + service_account_client_id=dict(type='str', aliases=['serviceAccountClientId']), + attributes=dict(type='list', elements='dict', options=attributes_spec), + access=dict(type='dict'), + groups=dict(type='list', default=[], elements='dict', options=groups_spec), + disableable_credential_types=dict(type='list', default=[], aliases=['disableableCredentialTypes'], elements='str'), + required_actions=dict(type='list', default=[], aliases=['requiredActions'], elements='str'), + credentials=dict(type='list', default=[], elements='dict', options=credential_spec), + federated_identities=dict(type='list', default=[], aliases=['federatedIdentities'], elements='str'), + client_consents=dict(type='list', default=[], aliases=['clientConsents'], elements='dict', options=client_consents_spec), + origin=dict(type='str'), + state=dict(choices=["absent", "present"], default='present'), + force=dict(type='bool', default=False), + ) + 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') + force = module.params.get('force') + username = module.params.get('username') + groups = module.params.get('groups') + + # Filter and map the parameters names that apply to the user + user_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'force', 'groups'] and + module.params.get(x) is not None] + + before_user = kc.get_user_by_username(username=username, realm=realm) + + if before_user is None: + before_user = {} + + changeset = {} + + for param in user_params: + new_param_value = module.params.get(param) + if param == 'attributes' and param in before_user: + old_value = kc.convert_keycloak_user_attributes_dict_to_module_list(attributes=before_user['attributes']) + else: + old_value = before_user[param] if param in before_user else None + if new_param_value != old_value: + if old_value is not None and param == 'attributes': + for old_attribute in old_value: + old_attribute_found = False + for new_attribute in new_param_value: + if new_attribute['name'] == old_attribute['name']: + old_attribute_found = True + if not old_attribute_found: + new_param_value.append(copy.deepcopy(old_attribute)) + if isinstance(new_param_value, dict): + changeset[camel(param)] = copy.deepcopy(new_param_value) + else: + changeset[camel(param)] = 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_user = copy.deepcopy(before_user) + desired_user.update(changeset) + + result['proposed'] = changeset + result['existing'] = before_user + + changed = False + + # Cater for when it doesn't exist (an empty dict) + if state == 'absent': + if not before_user: + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Role does not exist, doing nothing.' + module.exit_json(**result) + else: + # Delete user + kc.delete_user(user_id=before_user['id'], realm=realm) + result["msg"] = 'User %s deleted' % (before_user['username']) + changed = True + + else: + after_user = {} + if force: # If the force option is set to true + # Delete the existing user + kc.delete_user(user_id=before_user["id"], realm=realm) + + if not before_user or force: + # Process a creation + changed = True + + if username is None: + module.fail_json(msg='username must be specified when creating a new user') + + if module._diff: + result['diff'] = dict(before='', after=desired_user) + + if module.check_mode: + module.exit_json(**result) + # Create the user + after_user = kc.create_user(userrep=desired_user, realm=realm) + result["msg"] = 'User %s created' % (desired_user['username']) + # Add user ID to new representation + desired_user['id'] = after_user["id"] + else: + excludes = [ + "access", + "notBefore", + "createdTimestamp", + "totp", + "credentials", + "disableableCredentialTypes", + "groups", + "clientConsents", + "federatedIdentities", + "requiredActions"] + # Add user ID to new representation + desired_user['id'] = before_user["id"] + + # Compare users + if not (is_struct_included(desired_user, before_user, excludes)): # If the new user does not introduce a change to the existing user + # Update the user + after_user = kc.update_user(userrep=desired_user, realm=realm) + changed = True + + # set user groups + if kc.update_user_groups_membership(userrep=desired_user, groups=groups, realm=realm): + changed = True + # Get the user groups + after_user["groups"] = kc.get_user_groups(user_id=desired_user["id"], realm=realm) + result["end_state"] = after_user + if changed: + result["msg"] = 'User %s updated' % (desired_user['username']) + else: + result["msg"] = 'No changes made for user %s' % (desired_user['username']) + + result['changed'] = changed + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/keycloak_user/README.md b/tests/integration/targets/keycloak_user/README.md new file mode 100644 index 0000000000..07ecc3f83e --- /dev/null +++ b/tests/integration/targets/keycloak_user/README.md @@ -0,0 +1,21 @@ + +# 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_user --python 3.10 --allow-unsupported + +Cleanup: + + podman|docker stop mykeycloak + diff --git a/tests/integration/targets/keycloak_user/aliases b/tests/integration/targets/keycloak_user/aliases new file mode 100644 index 0000000000..0abc6a4671 --- /dev/null +++ b/tests/integration/targets/keycloak_user/aliases @@ -0,0 +1,4 @@ +# Copyright (c) 2023, INSPQ Philippe Gauthier (@elfelip) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +unsupported diff --git a/tests/integration/targets/keycloak_user/tasks/main.yml b/tests/integration/targets/keycloak_user/tasks/main.yml new file mode 100644 index 0000000000..0f1fe152d0 --- /dev/null +++ b/tests/integration/targets/keycloak_user/tasks/main.yml @@ -0,0 +1,114 @@ +# Copyright (c) 2022, Dušan Marković (@bratwurzt) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- 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 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 + +- 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 }}" + service_accounts_enabled: true + state: present + register: client + + +- 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: "{{ keycloak_client_role }}" + description: "{{ description_1 }}" + state: present + +- name: Create new groups + community.general.keycloak_group: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ item.name }}" + state: present + with_items: "{{ keycloak_user_groups }}" + +- name: Create user + community.general.keycloak_user: + auth_keycloak_url: "{{ url }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + auth_realm: "{{ admin_realm }}" + username: "{{ keycloak_username }}" + realm: "{{ realm }}" + first_name: Ceciestes + last_name: Untestes + email: ceciestuntestes@test.com + groups: "{{ keycloak_user_groups }}" + attributes: "{{ keycloak_user_attributes }}" + state: present + register: create_result + +- name: debug + debug: + var: create_result + +- name: Assert user is created + assert: + that: + - create_result.changed + - create_result.end_state.username == 'test' + - create_result.end_state.attributes | length == 3 + - create_result.end_state.groups | length == 2 + +- name: Delete User + community.general.keycloak_user: + auth_keycloak_url: "{{ url }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + auth_realm: "{{ admin_realm }}" + username: "{{ keycloak_username }}" + realm: "{{ realm }}" + first_name: Ceciestes + last_name: Untestes + email: ceciestuntestes@test.com + groups: "{{ keycloak_user_groups }}" + attributes: "{{ keycloak_user_attributes }}" + state: absent + register: delete_result + +- name: debug + debug: + var: delete_result + +- name: Assert user is deleted + assert: + that: + - delete_result.changed + - delete_result.end_state | length == 0 diff --git a/tests/integration/targets/keycloak_user/vars/main.yml b/tests/integration/targets/keycloak_user/vars/main.yml new file mode 100644 index 0000000000..9962aba548 --- /dev/null +++ b/tests/integration/targets/keycloak_user/vars/main.yml @@ -0,0 +1,46 @@ +--- +# Copyright (c) 2022, Dušan Marković (@bratwurzt) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +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 + +keycloak_username: test +keycloak_service_account_client_id: "{{ client_id }}" +keycloak_user_realm_roles: + - name: offline_access + - name: "{{ role }}" +keycloak_client_role: test +keycloak_user_client_roles: + - client_id: "{{ client_id }}" + roles: + - name: "{{ keycloak_client_role }}" + - client_id: "{{ realm }}-realm" + roles: + - name: view-users + - name: query-users +keycloak_user_attributes: + - name: attr1 + values: + - value1s + state: present + - name: attr2 + values: + - value2s + state: present + - name: attr3 + values: + - value3s + state: present +keycloak_user_groups: + - name: test + state: present + - name: test2 diff --git a/tests/unit/plugins/modules/test_keycloak_user.py b/tests/unit/plugins/modules/test_keycloak_user.py new file mode 100644 index 0000000000..f5a4e26f8a --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_user.py @@ -0,0 +1,354 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +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 patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules import keycloak_user + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_user_by_username=None, + create_user=None, + update_user_groups_membership=None, + get_user_groups=None, + delete_user=None, + update_user=None): + """Mock context manager for patching the methods in KeycloakAPI that contact the Keycloak server + + Patches the `get_user_by_username` and `create_user` methods + + """ + + obj = keycloak_user.KeycloakAPI + with patch.object(obj, 'get_user_by_username', side_effect=get_user_by_username) as mock_get_user_by_username: + with patch.object(obj, 'create_user', side_effect=create_user) as mock_create_user: + with patch.object(obj, 'update_user_groups_membership', side_effect=update_user_groups_membership) as mock_update_user_groups_membership: + with patch.object(obj, 'get_user_groups', side_effect=get_user_groups) as mock_get_user_groups: + with patch.object(obj, 'delete_user', side_effect=delete_user) as mock_delete_user: + with patch.object(obj, 'update_user', side_effect=update_user) as mock_update_user: + yield mock_get_user_by_username, mock_create_user, mock_update_user_groups_membership,\ + mock_get_user_groups, mock_delete_user, mock_update_user + + +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 TestKeycloakUser(ModuleTestCase): + def setUp(self): + super(TestKeycloakUser, self).setUp() + self.module = keycloak_user + + def test_add_new_user(self): + """Add a new user""" + + module_args = { + 'auth_keycloak_url': 'https: // auth.example.com / auth', + 'token': '{{ access_token }}', + 'state': 'present', + 'realm': 'master', + 'username': 'test', + 'groups': [] + } + return_value_get_user_by_username = [None] + return_value_update_user_groups_membership = [False] + return_get_user_groups = [[]] + return_create_user = [{'id': '123eqwdawer24qwdqw4'}] + return_delete_user = None + return_update_user = None + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_user_by_username=return_value_get_user_by_username, + create_user=return_create_user, + update_user_groups_membership=return_value_update_user_groups_membership, + get_user_groups=return_get_user_groups, + update_user=return_update_user, + delete_user=return_delete_user) \ + as (mock_get_user_by_username, + mock_create_user, + mock_update_user_groups_membership, + mock_get_user_groups, + mock_delete_user, + mock_update_user): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_user_by_username.call_count, 1) + self.assertEqual(mock_create_user.call_count, 1) + self.assertEqual(mock_update_user_groups_membership.call_count, 1) + self.assertEqual(mock_get_user_groups.call_count, 1) + self.assertEqual(mock_update_user.call_count, 0) + self.assertEqual(mock_delete_user.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_add_exiting_user_no_change(self): + """Add a new user""" + + module_args = { + 'auth_keycloak_url': 'https: // auth.example.com / auth', + 'token': '{{ access_token }}', + 'state': 'present', + 'realm': 'master', + 'username': 'test', + 'groups': [] + } + return_value_get_user_by_username = [ + { + 'id': '123eqwdawer24qwdqw4', + 'username': 'test', + 'groups': [], + 'enabled': True, + 'emailVerified': False, + 'disableableCredentialTypes': [], + 'requiredActions': [], + 'credentials': [], + 'federatedIdentities': [], + 'clientConsents': [] + } + ] + return_value_update_user_groups_membership = [False] + return_get_user_groups = [[]] + return_create_user = None + return_delete_user = None + return_update_user = None + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_user_by_username=return_value_get_user_by_username, + create_user=return_create_user, + update_user_groups_membership=return_value_update_user_groups_membership, + get_user_groups=return_get_user_groups, + update_user=return_update_user, + delete_user=return_delete_user) \ + as (mock_get_user_by_username, + mock_create_user, + mock_update_user_groups_membership, + mock_get_user_groups, + mock_delete_user, + mock_update_user): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_user_by_username.call_count, 1) + self.assertEqual(mock_create_user.call_count, 0) + self.assertEqual(mock_update_user_groups_membership.call_count, 1) + self.assertEqual(mock_get_user_groups.call_count, 1) + self.assertEqual(mock_update_user.call_count, 0) + self.assertEqual(mock_delete_user.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_update_user_with_group_changes(self): + """Update groups for a user""" + + module_args = { + 'auth_keycloak_url': 'https: // auth.example.com / auth', + 'token': '{{ access_token }}', + 'state': 'present', + 'realm': 'master', + 'username': 'test', + 'first_name': 'test', + 'last_name': 'user', + 'groups': [{ + 'name': 'group1', + 'state': 'present' + }] + } + return_value_get_user_by_username = [ + { + 'id': '123eqwdawer24qwdqw4', + 'username': 'test', + 'groups': [], + 'enabled': True, + 'emailVerified': False, + 'disableableCredentialTypes': [], + 'requiredActions': [], + 'credentials': [], + 'federatedIdentities': [], + 'clientConsents': [] + } + ] + return_value_update_user_groups_membership = [True] + return_get_user_groups = [['group1']] + return_create_user = None + return_delete_user = None + return_update_user = [ + { + 'id': '123eqwdawer24qwdqw4', + 'username': 'test', + 'first_name': 'test', + 'last_name': 'user', + 'enabled': True, + 'emailVerified': False, + 'disableableCredentialTypes': [], + 'requiredActions': [], + 'credentials': [], + 'federatedIdentities': [], + 'clientConsents': [] + } + ] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_user_by_username=return_value_get_user_by_username, + create_user=return_create_user, + update_user_groups_membership=return_value_update_user_groups_membership, + get_user_groups=return_get_user_groups, + update_user=return_update_user, + delete_user=return_delete_user) \ + as (mock_get_user_by_username, + mock_create_user, + mock_update_user_groups_membership, + mock_get_user_groups, + mock_delete_user, + mock_update_user): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_user_by_username.call_count, 1) + self.assertEqual(mock_create_user.call_count, 0) + self.assertEqual(mock_update_user_groups_membership.call_count, 1) + self.assertEqual(mock_get_user_groups.call_count, 1) + self.assertEqual(mock_update_user.call_count, 1) + self.assertEqual(mock_delete_user.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_delete_user(self): + """Delete a user""" + + module_args = { + 'auth_keycloak_url': 'https: // auth.example.com / auth', + 'token': '{{ access_token }}', + 'state': 'absent', + 'realm': 'master', + 'username': 'test', + 'groups': [] + } + return_value_get_user_by_username = [ + { + 'id': '123eqwdawer24qwdqw4', + 'username': 'test', + 'groups': [], + 'enabled': True, + 'emailVerified': False, + 'disableableCredentialTypes': [], + 'requiredActions': [], + 'credentials': [], + 'federatedIdentities': [], + 'clientConsents': [] + } + ] + return_value_update_user_groups_membership = None + return_get_user_groups = None + return_create_user = None + return_delete_user = None + return_update_user = None + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_user_by_username=return_value_get_user_by_username, + create_user=return_create_user, + update_user_groups_membership=return_value_update_user_groups_membership, + get_user_groups=return_get_user_groups, + update_user=return_update_user, + delete_user=return_delete_user) \ + as (mock_get_user_by_username, + mock_create_user, + mock_update_user_groups_membership, + mock_get_user_groups, + mock_delete_user, + mock_update_user): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_user_by_username.call_count, 1) + self.assertEqual(mock_create_user.call_count, 0) + self.assertEqual(mock_update_user_groups_membership.call_count, 0) + self.assertEqual(mock_get_user_groups.call_count, 0) + self.assertEqual(mock_update_user.call_count, 0) + self.assertEqual(mock_delete_user.call_count, 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()