From 9b9987aeee6e386172daf0af85a27ef2887529cf Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Wed, 6 Feb 2019 16:56:08 +0100 Subject: [PATCH 01/79] Add new module managing keycloak user --- lib/ansible/module_utils/keycloak.py | 172 ++++++- .../identity/keycloak/keycloak_user.py | 425 ++++++++++++++++++ test/units/modules/identity/__init__.py | 0 .../modules/identity/keycloak/__init__.py | 0 .../identity/keycloak/test_keycloak_user.py | 401 +++++++++++++++++ 5 files changed, 996 insertions(+), 2 deletions(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_user.py create mode 100644 test/units/modules/identity/__init__.py create mode 100644 test/units/modules/identity/keycloak/__init__.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_user.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index d4855edc8c3237..c29bf3c8d0f4fb 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -46,6 +46,9 @@ URL_GROUPS = "{url}/admin/realms/{realm}/groups" URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" + def keycloak_argument_spec(): """ @@ -93,7 +96,6 @@ def _connect(self): # Remove empty items, for instance missing client_secret payload = dict((k, v) for k, v in payload.items() if v is not None) - try: r = json.load(open_url(auth_url, method='POST', validate_certs=self.validate_certs, data=urlencode(payload))) @@ -102,7 +104,8 @@ def _connect(self): % (auth_url, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain access token from %s: %s' - % (auth_url, str(e))) + % (auth_url, str(e)), + payload=payload) if 'access_token' in r: self.token = r['access_token'] @@ -472,3 +475,168 @@ def delete_group(self, name=None, groupid=None, realm="master"): except Exception as e: self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) + + def get_users(self, realm='master', filter=None): + """ Obtains user representations for users in a realm + + :param realm: realm to be queried + :param filter: if defined, only the user with userid specified in the filter is returned + :return: list of dicts of users representations + """ + userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) + if filter is not None: + userlist_url += '?userId=%s' % filter + + try: + user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' + % (realm, str(e))) + + def get_user_by_id(self, id, realm='master'): + """ Obtain user representation by id + + :param id: id (not name) of user to be queried + :param realm: realm to be queried + :return: dict of user representation or None if none matching exists + """ + url = URL_USER.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.load( + open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + + def get_user_id(self, name, realm='master'): + """ Obtain user id by name + + :param name: name of user to be queried + :param realm: realm to be queried + :return: user id (usually a UUID) + """ + result = self.get_user_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def get_user_by_name(self, name, realm='master'): + """ Obtain user representation by name + + :param name: name of user to be queried + :param realm: user from this realm + :return: dict of user representation or None if none matching exist + """ + result = self.get_users(realm) + if isinstance(result, list): + result = [x for x in result if x['username'] == name] + if len(result) > 0: + return result[0] + return None + + def create_user(self, user_representation, realm="master"): + """ Create a user in keycloak + + :param user_representation: user representation of user to be created. Must at least contain field userId + :param realm: realm for user to be created + :return: HTTPResponse object on success + """ + # Keycloak wait username as key for the keycloak user username. For the + # keycloak modules, the username is an alias of the auth_username, thus + # cannot be used for the users. + try: + user_name = user_representation.pop('keycloakUsername') + except KeyError: + self.module.fail_json( + msg='User name needs to be specified when creating a new user', + user_representation=user_representation + ) + else: + user_representation.update({'username': user_name}) + user_url = URL_USERS.format(url=self.baseurl, realm=realm) + + try: + return open_url(user_url, method='POST', headers=self.restheaders, + data=json.dumps(user_representation), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create user %s in realm %s: %s' + % (user_representation['username'], realm, str(e)), + payload=user_representation) + + def update_user(self, uuid, user_representation, realm="master"): + """ Update an existing user + :param uuid: id of user to be updated in Keycloak + :param user_representation: corresponding (partial/full) user representation with updates + :param realm: realm the user is in + :return: HTTPResponse object on success + """ + # Keycloak response with an error 409 conflict if a username is send + # when updating an user. To avoid this, if the user was designated by + # its username, it is deleted. + try: + user_representation.pop('keycloakUsername') + except KeyError: + pass + try: + keycloak_attributes = user_representation.pop('keycloakAttributes') + except KeyError: + pass + else: + user_representation.update({'attributes': keycloak_attributes}) + + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) + + try: + return open_url(user_url, method='PUT', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update user %s in realm %s: %s' % (uuid, realm, str(e)), + user_representation=user_representation, + user_url=user_url + ) + + def delete_user(self, id, realm="master"): + """ Delete a user from Keycloak + + :param id: id (not userId) of user to be deleted + :param realm: realm of user to be deleted + :return: HTTPResponse object on success + """ + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(user_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete user %s in realm %s: %s' + % (id, realm, str(e))) + + def get_json_from_url(self, url): + try: + user_json = json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to get: %s' % (url)) + except Exception as e: + self.module.fail_json(msg='Could not obtain url: %s' % (url)) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py new file mode 100644 index 00000000000000..e4cc77f681a76f --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -0,0 +1,425 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: keycloak_user + +short_description: Allows administration of Keycloak users via Keycloak API + +version_added: "2.9" + +description: + - This module allows the administration of Keycloak clients 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/4.8/rest-api/index.html/). + Aliases are provided so camelCased versions can be used as well. If they are in conflict + with ansible names or previous used names, they will be prefixed by "keycloak". + +options: + state: + description: + - State of the user + - On C(present), the user will be created (or updated if it exists already). + - On C(absent), the user will be removed if it exists + choices: [ present, absent ] + default: present + + realm: + description: + - The realm to create the user in. + default: master + + attributes: + description: + – a dictionary with the key and the value to put in keycloak. + Keycloak will always return the value in a list of one element. + Keys and values are converted into string. + required: false + + user_id: + description: + - user_id of client to be worked on. This is usually an UUID. This and I(client_username) + are mutually exclusive. + aliases: [ userId ] + + keycloak_username: + description: + - username of user to be worked on. This and I(user_id) are mutually exclusive. + - keycloak lower the username + aliases: [ keycloakUsername ] + + email_verified: + description: + - show if the user email have been verified + required: false + type: bool + aliases: [ emailVerified ] + + enabled: + description: + - show if the user can logged in + required: false + type: bool + + email: + description: + - the user email + - this module does not check the validity of the email + - when using the api, there is no check about the validity of the email in keycloak + - but with manual action, the format is checked + required: false + + required_actions: + description: + - a list of actions to be done by the user + - each element must be in the choices + choices: [ UPDATE_PROFILE, VERIFY_EMAIL, UPDATE_PASSWORD, CONFIGURE_TOTP ] + aliases: [ requiredActions ] + + first_name: + description: + - the user first name + aliases: [ firstName ] + + last_name: + description: + - the user last name + aliases: [ lastName ] + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = ''' +# Pass in a message +- name: Create or update Keycloak users template (minimal) + keycloak_user: + auth_client_id: admin-cli + auth_keycloak_url: http://localhost:8080/auth + auth_realm: master + auth_username: admin_test + auth_password: admin_password + keycloak_username: userTest1 +- name: Delete previous user + keycloak_user: + auth_client_id: admin-cli + auth_keycloak_url: http://localhost:8080/auth + auth_realm: master + auth_username: admin_test + auth_password: admin_password + keycloak_username: userTest1 + state: absent +- name: Update keycloak user with all options + keycloak_user: + auth_client_id: admin-cli + auth_keycloak_url: http://localhost:8080/auth + auth_realm: master + auth_username: admin_test + auth_password: admin_password + keycloak_username: userTest1 + email_verified: yes + enabled: yes + email: userTest@domain.org + first_name: user + last_name: test + required_actions: [ UPDATE_PROFILE, CONFIGURE_TOTP ] + attributes: {'one key': 'one value', 'another key': 42} +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "User usertest1 has been updated" + +proposed: + description: user representation of proposed changes to user + returned: always + type: dict + sample: { + "email": "userTest1@domain.org", + "attributes": {"onekey": "RS256"} + } +existing: + description: client representation of existing client (sample is truncated) + returned: always + type: dict + sample: { + "enabled": false, + "attributes": { + "onekey": ["RS256"], + } + } +end_state: + description: client representation of client after module execution (sample is truncated) + returned: always + type: dict + sample: { + "enabled": false, + "attributes": { + "onekey": ["RS256"], + } + } +''' + +from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.basic import AnsibleModule + + +AUTHORIZED_REQUIRED_ACTIONS = [ + 'CONFIGURE_TOTP', 'UPDATE_PASSWORD', 'UPDATE_PROFILE', 'VERIFY_EMAIL'] +# is this compatible with native string stategy? +AUTHORIZED_ATTRIBUTE_VALUE_TYPE = (str, int, float, bool) + + +def sanitize_user_representation(user_representation): + """ Removes probably sensitive details from a user representation + + :param userrep: the userrep dict to be sanitized + :return: sanitized userrep dict + """ + result = user_representation.copy() + if 'credentials' in result: + # check if this value are to sanitize + for credential_key in ['hashedSaltedValue', 'salt']: + if credential_key in result['credentials']: + result['credentials'][credential_key] = 'no_log' + return result + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + + keycloak_username=dict(type='str', aliases=['keycloakUsername']), + user_id=dict(type='str', aliases=['userId']), + + email_verified=dict(type='bool', aliases=['emailVerified']), + enabled=dict(type='bool'), + attributes=dict(type='dict'), + email=dict(type='str'), + first_name=dict(type='str', aliases=['firstName']), + last_name=dict(type='str', aliases=['lastName']), + required_actions=dict(type='list', aliases=['requiredActions'], + choices=AUTHORIZED_REQUIRED_ACTIONS) + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['keycloak_username', 'user_id']]), + mutually_exclusive=[['keycloak_username', 'user_id']] + ) + + realm = module.params.get('realm') + state = module.params.get('state') + given_user_id = {'name': module.params.get('keycloak_username')} + if not given_user_id['name']: + given_user_id.update({'id': module.params.get('user_id')}) + given_user_id.pop('name') + else: + given_user_id.update({'name': given_user_id['name'].lower()}) + + if not attributes_format_is_correct(module.params.get('attributes')): + module.fail_json(msg=( + 'Attributes are not in the correct format. Should be a dictionary with ' + 'one value per key as string, integer and boolean')) + + kc = KeycloakAPI(module) + before_user = get_initial_user(given_user_id, kc, realm) + + result = create_result(before_user, module) + + # If the user does not exist yet, before_user is still empty + if before_user == dict(): + if state == 'absent': + do_nothing_and_exit(kc, result) + + create_user(kc, result, realm, given_user_id) + else: + if state == 'present': + updating_user(kc, result, realm, given_user_id) + else: + deleting_user(kc, result, realm, given_user_id) + + +def attributes_format_is_correct(given_attributes): + if not given_attributes: + return True + for one_value in given_attributes.values(): + if isinstance(one_value, list): + if not attribute_as_list_format_is_correct(one_value): + return False + continue + if isinstance(one_value, dict): + return False + if not isinstance(one_value, AUTHORIZED_ATTRIBUTE_VALUE_TYPE): + return False + return True + + +def attribute_as_list_format_is_correct(one_value, first_call=True): + if isinstance(one_value, list) and first_call: + if len(one_value) > 1: + return False + return attribute_as_list_format_is_correct(one_value[0], False) + else: + if not isinstance(one_value, AUTHORIZED_ATTRIBUTE_VALUE_TYPE): + return False + return True + + +def get_initial_user(given_user_id, kc, realm): + if 'name' in given_user_id: + before_user = kc.get_user_by_name(given_user_id['name'], realm=realm) + else: + before_user = kc.get_user_by_id(given_user_id['id'], realm=realm) + if before_user is None: + before_user = dict() + return before_user + + +def create_result(before_user, module): + changeset = create_changeset(module) + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, + end_state={}) + result['proposed'] = changeset + result['existing'] = before_user + return result + + +def create_changeset(module): + user_params = [ + x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + module.params.get(x) is not None] + changeset = dict() + for user_param in user_params: + new_param_value = module.params.get(user_param) + + # some lists in the Keycloak API are sorted, some are not. + if isinstance(new_param_value, list): + if user_param in ['attributes']: + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + + changeset[camel(user_param)] = new_param_value + return changeset + + +def do_nothing_and_exit(kc, result): + module = kc.module + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'User does not exist, doing nothing.' + module.exit_json(**result) + + +def create_user(kc, result, realm, given_user_id): + module = kc.module + user_to_create = result['proposed'] + result['changed'] = True + + if module._diff: + result['diff'] = dict(before='', + after=sanitize_user_representation(user_to_create)) + if module.check_mode: + module.exit_json(**result) + + response = kc.create_user(user_to_create, realm=realm) + after_user = kc.get_json_from_url(response.headers.get('Location')) + result['end_state'] = sanitize_user_representation(after_user) + result['msg'] = 'User %s has been created.' % given_user_id['name'] + module.exit_json(**result) + + +def updating_user(kc, result, realm, given_user_id): + module = kc.module + changeset = result['proposed'] + before_user = result['existing'] + updated_user = before_user.copy() + updated_user.update(changeset) + result['changed'] = True + + if module.check_mode: + # We can only compare the current user with the proposed updates we have + if module._diff: + result['diff'] = dict( + before=sanitize_user_representation(before_user), + after=sanitize_user_representation(updated_user)) + result['changed'] = (before_user != updated_user) + module.exit_json(**result) + + if 'name' in given_user_id.keys(): + asked_id = kc.get_user_id(given_user_id['name'], realm=realm) + else: + asked_id = given_user_id['id'] + kc.update_user(asked_id, changeset, realm=realm) + after_user = kc.get_user_by_id(asked_id, realm=realm) + if before_user == after_user: + result['changed'] = False + + if module._diff: + result['diff'] = dict( + before=sanitize_user_representation(before_user), + after=sanitize_user_representation(after_user)) + + result['end_state'] = sanitize_user_representation(after_user) + result['msg'] = 'User %s has been updated.' % list(given_user_id.values())[0] + module.exit_json(**result) + + +def deleting_user(kc, result, realm, given_user_id): + module = kc.module + before_user = result['existing'] + result['proposed'] = {} + result['changed'] = True + if module._diff: + result['diff']['before'] = sanitize_user_representation( + before_user) + result['diff']['after'] = '' + if module.check_mode: + module.exit_json(**result) + if 'name' in given_user_id: + asked_id = kc.get_user_id(given_user_id['name'], realm=realm) + else: + asked_id = given_user_id['id'] + kc.delete_user(asked_id, realm=realm) + result['proposed'] = dict() + result['end_state'] = dict() + result['msg'] = 'User %s has been deleted.' % list(given_user_id.values())[0] + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/__init__.py b/test/units/modules/identity/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/units/modules/identity/keycloak/__init__.py b/test/units/modules/identity/keycloak/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/units/modules/identity/keycloak/test_keycloak_user.py b/test/units/modules/identity/keycloak/test_keycloak_user.py new file mode 100644 index 00000000000000..34744c56befa23 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_user.py @@ -0,0 +1,401 @@ +from __future__ import (absolute_import, division, print_function) + +import json +from itertools import count + +import pytest + +from ansible.modules.identity.keycloak import keycloak_user +from units.modules.utils import ( + AnsibleExitJson, AnsibleFailJson, fail_json, exit_json, set_module_args) +from ansible.module_utils.six import StringIO + +LIST_USER_RESPONSE_ADMIN_ONLY = r"""[ + { + "id": "882ddb5e-51d0-4aa9-8cb7-556f53e62e90", + "createdTimestamp": 1549805949269, + "username": "test_admin", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + }, + { + "id": "883eeb5e-51d0-4aa9-8cb7-667f53e62e90", + "createdTimestamp": 1549806949269, + "username": "user1", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + }, + { + "id": "994eeb5e-62e1-4bb9-8cb7-667f53e62f01", + "createdTimestamp": 1549806949269, + "username": "to_delete", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + } +]""" + +TO_DELETE_USER = """{ + "id": "994eeb5e-62e1-4bb9-8cb7-667f53e62f01", + "createdTimestamp": 1549806949269, + "username": "to_delete", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + }""" + +CREATED_USER_RESPONSE = """{ + "id": "992ddb5e-51d0-4aa9-8cb7-556f53e62e91", + "createdTimestamp": 1549805950370, + "username": "to_add_user", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } +} +""" + +UPDATED_USER = """{ + "id": "883eeb5e-51d0-4aa9-8cb7-667f53e62e90", + "createdTimestamp": 1549806949269, + "username": "user1", + "enabled": true, + "totp": false, + "email": "user1@domain.net", + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + }""" + +GET_USER_BY_ID = """{ + "id": "883eeb5e-51d0-4aa9-8cb7-667f53e62e90", + "createdTimestamp": 1549806949269, + "username": "user1", + "enabled": true, + "totp": false, + "emailVerified": false, + "disableableCredentialTypes": [ + "password" + ], + "requiredActions": [], + "notBefore": 0, + "access": { + "manageGroupMembership": true, + "view": true, + "mapRoles": true, + "impersonate": true, + "manage": true + } + } +""" + + +@pytest.fixture +def url_mock_keycloak(mocker): + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), RESPONSE_ADMIN_ONLY), + autospec=True + ) + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +class CreatedUserMockResponse(object): + def __init__(self): + self.headers = {'Location': 'http://keycloak.url/auth/admin/realms/master/users/992ddb5e-51d0-4aa9-8cb7-556f53e62e91'} + + +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 + + +RESPONSE_ADMIN_ONLY = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "a long token"}'), + 'http://keycloak.url/auth/admin/realms/master/users': { + 'GET': create_wrapper(LIST_USER_RESPONSE_ADMIN_ONLY), + 'POST': CreatedUserMockResponse() + }, + 'http://keycloak.url/auth/admin/realms/master/users/992ddb5e-51d0-4aa9-8cb7-556f53e62e91': create_wrapper( + CREATED_USER_RESPONSE), + 'http://keycloak.url/auth/admin/realms/master/users/994eeb5e-62e1-4bb9-8cb7-667f53e62f01': { + 'GET': create_wrapper(TO_DELETE_USER), + 'DELETE': None + } +} + + +def test_state_absent_should_not_create_absent_user(monkeypatch, url_mock_keycloak): + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + set_module_args( + { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'keycloak_username': 'to_not_add_user', + 'state': 'absent' + }) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_user.main() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'User does not exist, doing nothing.' + + +def test_state_present_should_create_absent_user(monkeypatch, url_mock_keycloak): + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + set_module_args( + { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'keycloak_username': 'to_add_user', + 'state': 'present' + }) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_user.main() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'User to_add_user has been created.' + + +@pytest.mark.parametrize('user_to_delete', [ + {'keycloak_username': 'to_delete'}, + {'user_id': '994eeb5e-62e1-4bb9-8cb7-667f53e62f01'}, +], ids=['with name', 'with id']) +def test_state_absent_should_delete_existing_user(monkeypatch, url_mock_keycloak, user_to_delete): + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'state': 'absent' + } + arguments.update(user_to_delete) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_user.main() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == ('User %s has been deleted.' % list(user_to_delete.values())[0]) + + +@pytest.fixture(params=[ + {'keycloak_username': 'user1'}, + {'user_id': '883eeb5e-51d0-4aa9-8cb7-667f53e62e90'}, + {'keycloak_username': 'UsEr1'}, +], ids=['with name', 'with_id', 'name with upper case']) +def build_user_update_request(request): + new_response_dictionary = RESPONSE_ADMIN_ONLY.copy() + if 'keycloak_username' in request.param.keys(): + new_response_dictionary.update({ + 'http://keycloak.url/auth/admin/realms/master/users/883eeb5e-51d0-4aa9-8cb7-667f53e62e90': { + 'PUT': None, + 'GET': create_wrapper(UPDATED_USER) + }}) + else: + new_response_dictionary .update({ + 'http://keycloak.url/auth/admin/realms/master/users/883eeb5e-51d0-4aa9-8cb7-667f53e62e90': { + 'PUT': None, + 'GET': [ + create_wrapper(GET_USER_BY_ID), + create_wrapper(UPDATED_USER) + ]}}) + return request.param, new_response_dictionary + + +@pytest.fixture() +def dynamic_url_for_user_update(mocker, build_user_update_request): + parameters, response_dictionary = build_user_update_request + return parameters, mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dictionary), + autospec=True + ) + + +def test_state_present_should_update_existing_user(monkeypatch, dynamic_url_for_user_update): + user_to_update, dummy = dynamic_url_for_user_update + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'state': 'present', + 'email': 'user1@domain.net' + } + arguments.update(user_to_update) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_user.main() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == ('User %s has been updated.' % list(user_to_update.values())[0].lower()) + assert ansible_exit_json['end_state'] == json.loads(UPDATED_USER) + + +@pytest.mark.parametrize('wrong_attributes', [ + {'list1': ['a', 'b', 'c']}, + {'list2': [['a', 2, 3]]}, + {'dict1': {'a': 2}}, + {'list3': [[['a']]]}, +], ids=['too long list', 'list into a list', 'dictionary as value', 'list russian doll']) +def test_wrong_attributes_type_should_raise_an_error(monkeypatch, wrong_attributes): + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'keycloak_username': 'user1', + 'attributes': wrong_attributes + } + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_user.main() + ansible_failed_json = exec_error.value.args[0] + assert ansible_failed_json['msg'] == ( + 'Attributes are not in the correct format. Should be a dictionary with' + ' one value per key as string, integer and boolean') + + +@pytest.fixture() +def url_for_fake_update(mocker): + new_response_dictionary = RESPONSE_ADMIN_ONLY.copy() + new_response_dictionary.update({ + 'http://keycloak.url/auth/admin/realms/master/users/883eeb5e-51d0-4aa9-8cb7-667f53e62e90': { + 'PUT': None, + 'GET': create_wrapper(UPDATED_USER) + }}) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), new_response_dictionary), + autospec=True + ) + + +def test_correct_attributes_type_should_pass(monkeypatch, url_for_fake_update): + """This test only check that accepted types don't raised errors. + There is no check on the returned values.""" + monkeypatch.setattr(keycloak_user.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_user.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'keycloak_username': 'user1', + 'attributes': { + 'int': 1, 'str': ['some text'], 'float': 0.1, 'bool': True}, + 'required_actions': ['CONFIGURE_TOTP', 'UPDATE_PASSWORD', 'UPDATE_PROFILE', 'VERIFY_EMAIL'] + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson): + keycloak_user.main() From 336385adc5bb1b38c4533ed43afee92c542708b8 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Tue, 19 Feb 2019 11:29:50 +0100 Subject: [PATCH 02/79] Add new module managing keycloak role This module allows to create, update and delete roles --- lib/ansible/module_utils/keycloak.py | 120 ++++- .../identity/keycloak/keycloak_role.py | 423 ++++++++++++++++++ .../identity/keycloak/test_keycloak_role.py | 389 ++++++++++++++++ 3 files changed, 931 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_role.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_role.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index c29bf3c8d0f4fb..9378a4f6981237 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -1,4 +1,5 @@ # Copyright (c) 2017, Eike Frost +# -*- coding: utf-8 -*- # # This code is part of Ansible, but is an independent component. # This particular file snippet, and this file snippet only, is BSD licensed. @@ -32,14 +33,18 @@ import json from ansible.module_utils.urls import open_url -from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils._text import to_text +from ansible.module_utils.six.moves.urllib.parse import urlencode, quote from ansible.module_utils.six.moves.urllib.error import HTTPError 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/{role_id}" URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{role_id}" +URL_REALM_ROLE_BY_ID = "{url}/admin/realms/{realm}/roles-by-id/{id}" URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" @@ -640,3 +645,116 @@ def get_json_from_url(self, url): self.module.fail_json(msg='API returned incorrect JSON when trying to get: %s' % (url)) except Exception as e: self.module.fail_json(msg='Could not obtain url: %s' % (url)) + + def get_role_url(self, role_id, realm='master', client_uuid=None): + if 'name' in role_id: + role_name = role_id['name'] + if client_uuid: + rolelist_url = URL_CLIENT_ROLE.format( + url=self.baseurl, realm=quote(realm), id=client_uuid, + role_id=quote(role_name)) + else: + rolelist_url = URL_REALM_ROLE.format( + url=self.baseurl, realm=quote(realm), role_id=quote(role_name)) + else: + rolelist_url = URL_REALM_ROLE_BY_ID.format( + url=self.baseurl, realm=realm, id=role_id['uuid']) + return rolelist_url + + def get_role(self, role_id, realm='master', client_uuid=None): + """ Obtain client template representation by id + :param role_id: id or name of role to be queried + :param realm: role from this realm + :return: dict of role representation or None if none matching exist + """ + role_url = self.get_role_url(role_id, realm, client_uuid) + + try: + return json.load( + open_url(role_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json( + msg='Could not obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + + def delete_role(self, role_id, realm="master"): + """ Delete a role from Keycloak + :param role_id: id of role (uuid or name) to be deleted + :param realm: realm of role to be deleted + :return: HTTPResponse object on success + """ + role_url = URL_REALM_ROLE_BY_ID.format(url=self.baseurl, realm=quote(realm), id=quote(role_id)) + + 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='Could not delete role %s in realm %s: %s' + % (role_id, realm, to_text(e))) + + def get_role_id(self, name, realm='master', client_uuid=None): + """ Obtain role id by name + :param name: name of role to be queried + :param realm: realm to be queried + :return: role id (usually a UUID) + """ + result = self.get_role(name, realm, client_uuid) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def create_role(self, role_representation, realm="master", client_uuid=None): + """ Create a role in keycloak + :param role_representation: role representation to be created. + :param realm: realm for role to be created + :return: HTTPResponse object on success + """ + if client_uuid: + role_url = URL_CLIENT_ROLES.format( + url=self.baseurl, realm=quote(realm), id=client_uuid) + role_representation.pop('clientId') + else: + role_url = URL_REALM_ROLES.format(url=self.baseurl, realm=quote(realm)) + + try: + return open_url(role_url, method='POST', headers=self.restheaders, + data=json.dumps(role_representation), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not create role %s in realm %s: %s' + % (to_text(role_representation['name']), to_text(realm), to_text(e)), + payload=role_representation) + + def update_role(self, role_id, role_representation, realm="master", client_uuid=None): + """ Update an existing role + :param role_id: id of role to be updated in Keycloak + :param role_representation: corresponding (partial/full) role representation with updates + :param realm: realm the role is in + :return: HTTPResponse object on success + """ + role_url = self.get_role_url(role_id, realm, client_uuid) + + try: + return open_url(role_url, method='PUT', headers=self.restheaders, + data=json.dumps(role_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update role %s in realm %s: %s' % ( + to_text(list(role_id.values())[0]), to_text(realm), to_text(e)), + role_representation=role_representation, + role_url=role_url + ) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_role.py b/lib/ansible/modules/identity/keycloak/keycloak_role.py new file mode 100644 index 00000000000000..9322c17f875ed7 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -0,0 +1,423 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = r''' +--- +module: keycloak_role + +short_description: Allows administration of Keycloak roles via Keycloak API + +version_added: "2.8" + +description: + - This module allows the administration of 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/4.8/rest-api/index.html/). + Aliases are provided so camelCased versions can be used as well. If they are in conflict + with ansible names or previous used names, they will be prefixed by "keycloak". + - This module does not manage composite roles. + +options: + state: + description: + - State of the role. + - On C(present), the role will be created (or updated if it exists already). + - On C(absent), the role will be removed if it exists. + type: str + choices: [present, absent] + default: present + + realm: + description: + - The realm to create the client in. + type: str + default: master + + attributes: + description: + – a dictionary with the key and the value to put in keycloak. + Keycloak will always return the value in a list of one element. + Keys and values are converted into string. + type: dict + required: false + + name: + description: + - the name of the role to modify. + - I(name) and I(id) are mutually exclusive. + type: str + + id: + description: + - the id (generally an uuid) of the role to modify. + - I(name) and I(id) are mutually exclusive. + - I(id) and I(client_id) are mutually exclusive. + type: str + + client_id: + description: + - client id of client where the role will be inserted. This is usually + an alphanumeric name chosen by you. + - the client must exist before this call. + - I(id) and I(client_id) are mutually exclusive. + type: str + aliases: [ clientId ] + required: false + + description: + description: + - The description associate to your role. + type: str + required: false + +extends_documentation_fragment: + - keycloak +author: + - Nicolas Duclert (@ndclt) + +''' + +EXAMPLES = r''' +- name: create or update keycloak role in realm (minimal example) + keycloak_role: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: role-test-1 + +- name: create or update keycloak role in client (minimal example) + keycloak_role: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + client_id: client-with-role + name: role-test-in-client-1 + +- name: create or update keycloak role in realm (with everything) + keycloak_role: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: role-test-1 + description: a long description in order to know about this role + attributes: {"a key": ["a value"], "an other key": [12]} + +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Role role-test has been updated" + +proposed: + description: role representation of proposed changes to role + returned: always + type: dict + sample: { + "description": "a new description", + "attributes": {"onekey": "RS256"} + } + +existing: + description: role representation of existing role (sample is truncated) + returned: always + type: dict + sample: { + "name": "role-test", + "description": "The old description", + "composite": False, + "attributes": { + "onekey": ["RS256"], + } + } + +end_state: + description: role representation of role after module execution (sample is truncated) + returned: always + type: dict + sample: { + "name": "role-test", + "description": "a new description", + "composite": False, + "attributes": { + "onekey": ["RS256"], + } + } +''' + +from ansible.module_utils._text import to_text +from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.basic import AnsibleModule + +AUTHORIZED_ATTRIBUTE_VALUE_TYPE = (str, int, float, bool) + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + name=dict(type='str'), + id=dict(type='str'), + client_id=dict(type='str', aliases=['clientId'], required=False), + description=dict(type='str', required=False), + attributes=dict(type='dict', required=False), + ) + + argument_spec.update(meta_args) + + # The id of the role is unique in keycloak and if it is given the + # client_id is not used. In order to avoid confusion, I set a mutual + # exclusion. + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['name', 'id']]), + mutually_exclusive=([ + ['name', 'id'], + ['id', 'client_id']]), + ) + realm = module.params.get('realm') + state = module.params.get('state') + given_role_id = {'name': module.params.get('name')} + if not given_role_id['name']: + given_role_id.update({'uuid': module.params.get('id')}) + given_role_id.pop('name') + client_id = module.params.get('client_id') + + if not attributes_format_is_correct(module.params.get('attributes')): + module.fail_json(msg=( + 'Attributes are not in the correct format. Should be a dictionary with ' + 'one value per key as string, integer and boolean')) + + kc = KeycloakAPI(module) + before_role, client_uuid = get_initial_role(given_role_id, kc, realm, client_id) + result = create_result(before_role, module) + + if before_role == dict(): + if state == 'absent': + do_nothing_and_exit(kc, result, realm, given_role_id, client_id, client_uuid) + create_role(kc, result, realm, given_role_id, client_id) + else: + if state == 'present': + updating_role(kc, result, realm, given_role_id, client_uuid) + else: + deleting_role(kc, result, realm, given_role_id, client_uuid) + + +def attributes_format_is_correct(given_attributes): + if not given_attributes: + return True + for one_value in given_attributes.values(): + if isinstance(one_value, list): + if not attribute_as_list_format_is_correct(one_value): + return False + continue + if isinstance(one_value, dict): + return False + if not isinstance(one_value, AUTHORIZED_ATTRIBUTE_VALUE_TYPE): + return False + return True + + +def attribute_as_list_format_is_correct(one_value, first_call=True): + if isinstance(one_value, list) and first_call: + if len(one_value) > 1: + return False + return attribute_as_list_format_is_correct(one_value[0], False) + else: + if not isinstance(one_value, AUTHORIZED_ATTRIBUTE_VALUE_TYPE): + return False + return True + + +def get_initial_role(given_role_id, kc, realm, client_id): + if client_id: + client_uuid = kc.get_client_id(client_id, realm) + else: + client_uuid = None + before_role = kc.get_role(given_role_id, realm=realm, client_uuid=client_uuid) + if before_role is None: + before_role = dict() + return before_role, client_uuid + + +def create_result(before_role, module): + changeset = create_changeset(module) + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, + end_state={}) + result['proposed'] = changeset + result['existing'] = before_role + return result + + +def create_changeset(module): + role_params = [ + x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + module.params.get(x) is not None] + changeset = dict() + + for role_param in role_params: + new_param_value = module.params.get(role_param) + + # some lists in the Keycloak API are sorted, some are not. + if isinstance(new_param_value, list): + if role_param in ['attributes']: + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + + changeset[camel(role_param)] = new_param_value + return changeset + + +def do_nothing_and_exit(kc, result, realm, given_role_id, client_id, client_uuid): + module = kc.module + message_role_id = list(given_role_id.values())[0] + if module._diff: + result['diff'] = dict(before='', after='') + if client_id: + if not client_uuid: + result['msg'] = ( + 'Client %s does not exist in %s, cannot found role %s in it, doing nothing.' + % (to_text(client_id), realm, to_text(message_role_id))) + else: + result['msg'] = ( + 'Role %s does not exist in client %s of realm %s, doing nothing.' + % (to_text(message_role_id), to_text(client_id), to_text(realm))) + else: + result['msg'] = ('Role %s does not exist in realm %s, doing nothing.' + % (to_text(message_role_id), to_text(realm))) + module.exit_json(**result) + + +def create_role(kc, result, realm, given_role_id, client_id): + if client_id: + client_uuid = kc.get_client_id(client_id, realm) + else: + client_uuid = None + module = kc.module + role_to_create = result['proposed'] + result['changed'] = True + + if module._diff: + result['diff'] = dict(before='', + after=role_to_create) + if module.check_mode: + module.exit_json(**result) + + if 'attributes' in role_to_create: + role_to_create.update( + {'attributes': put_attributes_values_in_list(role_to_create['attributes'])}) + + response = kc.create_role(role_to_create, realm=realm, client_uuid=client_uuid) + if 'attributes' in role_to_create: + # update the created role with attributes because keycloak does not + # take it into account when creating the role + kc.update_role(given_role_id, role_to_create, realm=realm, client_uuid=client_uuid) + after_role = kc.get_json_from_url(response.headers.get('Location')) + result['end_state'] = after_role + result['msg'] = 'Role %s has been created.' % to_text(given_role_id['name']) + module.exit_json(**result) + + +def updating_role(kc, result, realm, given_role_id, client_uuid): + module = kc.module + changeset = result['proposed'] + before_role = result['existing'] + updated_role = before_role.copy() + updated_role.update(changeset) + result['changed'] = True + + if module.check_mode: + # We can only compare the current role with the proposed updates we have + if module._diff: + result['diff'] = dict( + before=before_role, + after=updated_role) + result['changed'] = (before_role != updated_role) + module.exit_json(**result) + + # put the current name in the change set in order to avoid a reset of the + # name which will make the role useless. + # See bug https://issues.jboss.org/browse/KEYCLOAK-9704 on keycloak. + changeset.update({'name': before_role['name']}) + + if 'attributes' in changeset: + changeset.update( + {'attributes': put_attributes_values_in_list(changeset['attributes'])}) + kc.update_role(given_role_id, changeset, realm=realm, client_uuid=client_uuid) + after_role = kc.get_role(given_role_id, realm=realm, client_uuid=client_uuid) + if before_role == after_role: + result['changed'] = False + + if module._diff: + result['diff'] = dict( + before=before_role, + after=after_role) + + result['end_state'] = after_role + result['msg'] = 'Role %s has been updated.' % to_text(list(given_role_id.values())[0]) + module.exit_json(**result) + + +def put_attributes_values_in_list(attributes_values): + new_attributes = {} + for key, value in attributes_values.items(): + if not isinstance(value, list): + new_attributes.update({key: [value]}) + else: + new_attributes.update({key: value}) + return new_attributes + + +def deleting_role(kc, result, realm, given_role_id, client_uuid): + module = kc.module + result['proposed'] = {} + result['changed'] = True + if module._diff: + result['diff']['before'] = result['existing'] + result['diff']['after'] = '' + if module.check_mode: + module.exit_json(**result) + asked_id = kc.get_role_id(given_role_id, realm, client_uuid) + kc.delete_role(asked_id, realm=realm) + result['proposed'] = dict() + result['end_state'] = dict() + result['msg'] = 'Role %s has been deleted.' % to_text(list(given_role_id.values())[0]) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_role.py b/test/units/modules/identity/keycloak/test_keycloak_role.py new file mode 100644 index 00000000000000..c73d027d639671 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -0,0 +1,389 @@ +# -*- coding: utf-8 -*- +from __future__ import (absolute_import, division, print_function) + +import json +from itertools import count + +import pytest + +from ansible.module_utils.six import StringIO +from ansible.modules.identity.keycloak import keycloak_role +from units.modules.utils import ( + AnsibleExitJson, AnsibleFailJson, fail_json, exit_json, set_module_args) +from ansible.module_utils._text import to_text +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "a long token"}'),} + +DEFAULT_ROLES = [ + {'id': 'c02533c5-d943-4274-9953-8b6a930ee74e', 'name': 'admin', + 'description': '${role_admin}', 'composite': True, + 'clientRole': False, 'containerId': 'master'}, + {'id': '9d78de2a-f790-432d-b24b-9d2102fd2957', + 'name': 'offline_access', + 'description': '${role_offline-access}', 'composite': False, + 'clientRole': False, 'containerId': 'master'}] + +MASTER_CLIENTS = [ + { + 'id': '11111111-1111-1111-1111-111111111111', + 'clientId': 'client-with-role', + 'name': 'Client with role number 1', + 'surrogateAuthRequired': False, + 'enabled': True, + 'clientAuthenticatorType': 'client-secret', + 'redirectUris': [], + 'webOrigins': [], + 'notBefore': 0, + 'bearerOnly': False, + 'consentRequired': False, + 'standardFlowEnabled': False, + 'implicitFlowEnabled': False, + 'directAccessGrantsEnabled': True, + 'serviceAccountsEnabled': False, + 'publicClient': True, + 'frontchannelLogout': False, + 'protocol': 'openid-connect', + 'attributes': {}, + 'authenticationFlowBindingOverrides': {}, + 'fullScopeAllowed': False, + 'nodeReRegistrationTimeout': 0, + 'defaultClientScopes': [ + 'web-origins', + 'role_list', + 'profile', + 'roles', + 'email' + ], + 'optionalClientScopes': [ + 'address', + 'phone', + 'offline_access' + ], + 'access': { + 'view': True, + 'configure': True, + 'manage': True + } + }, +] + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def raise_404(url): + def _raise_404(): + raise HTTPError(url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('')) + return _raise_404 + + +@pytest.fixture +def mock_absent_role_url(mocker): + absent_role_url = CONNECTION_DICT.copy() + absent_role_url.update({ + 'http://keycloak.url/auth/admin/realms/master/roles/absent': raise_404('master/roles/absent'), + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/00000000-0000-0000-0000-000000000000': + raise_404('roles/00000000-0000-0000-0000-000000000000'), + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=absent-client': create_wrapper(json.dumps({})), + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/absent': + raise_404('clients/11111111-1111-1111-1111-111111111111/roles/absent') + + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_role_url), + autospec=True + ) + + +@pytest.mark.parametrize('mutual_exclusive', [ + {'name': 'a', 'id': 'very-long-uuid'}, + {'id': 'very-long-uuid', 'client_id': 'client-with-role'} +]) +def test_mutually_exclusive_arguments_should_raise_an_error(monkeypatch, mutual_exclusive): + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + set_module_args(mutual_exclusive.copy()) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_role.run_module() + + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'parameters are mutually exclusive: %s' % ( + to_text(', '.join(mutual_exclusive.keys()))) + + +@pytest.mark.parametrize('role_identifier,error_message', [ + ({'name': 'absent'}, 'Role absent does not exist in realm master'), + ({'id': '00000000-0000-0000-0000-000000000000'}, + 'Role 00000000-0000-0000-0000-000000000000 does not exist in realm master'), + ({'client_id': 'absent-client', 'name': 'absent'}, + 'Client absent-client does not exist in master, cannot found role absent in it'), + ({'client_id': 'client-with-role', 'name': 'absent'}, + 'Role absent does not exist in client client-with-role of realm master'), +], ids=['with name', 'with id', 'role in client, client does not exist', + 'role name in client with id']) +def test_state_absent_should_not_create_absent_role( + monkeypatch, role_identifier, error_message, mock_absent_role_url): + """This function mainly test the get_initial_role and do_nothing_and_exit functions + """ + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent' + } + arguments.update(role_identifier) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_role.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == (error_message + ', doing nothing.') + + +@pytest.fixture +def mock_delete_role_urls(mocker): + delete_role_urls = CONNECTION_DICT.copy() + to_delete_role_in_master = { + 'id': 'cccccccc-d943-4274-9953-8b6a930ee74e', 'name': 'to delete', + 'description': 'to be deleted during test', 'composite': False, + 'clientRole': False, 'containerId': 'master'} + to_delete_role_in_client = { + "id": "bbbbbbbb-acca-463b-bd93-2e7fd66022f6", "name": "to delete", + "composite": False, "clientRole": True, + "containerId": "11111111-1111-1111-1111-111111111111", "attributes": {}} + delete_role_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/roles/to%20delete': create_wrapper(json.dumps(to_delete_role_in_master)), + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/to%20delete': create_wrapper(json.dumps(to_delete_role_in_client)), + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/cccccccc-d943-4274-9953-8b6a930ee74e': None, + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/bbbbbbbb-acca-463b-bd93-2e7fd66022f6': { + 'DELETE': None, + 'GET': create_wrapper(json.dumps(to_delete_role_in_master)) + } + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), delete_role_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments', [ + {'name': 'to delete'}, + {'name': 'to delete', 'client_id': 'client-with-role'}, + {'id': 'bbbbbbbb-acca-463b-bd93-2e7fd66022f6'} +], ids=['role in realm identified by name', 'role in client identified by name', + 'role identified by id']) +def test_state_absent_with_existing_role_should_delete_the_role( + monkeypatch, extra_arguments, mock_delete_role_urls): + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_role.run_module() + ansible_exit_json = exec_error.value.args[0] + role_identifier = list(extra_arguments.values())[0] + assert ansible_exit_json['msg'] == 'Role %s has been deleted.' % role_identifier + + +class CreatedUserMockResponse(object): + def __init__(self, role_name, client_uuid=None): + if client_uuid: + destination_url = 'http://keycloak.url/auth/admin/realms/master/clients/{uuid}/roles/{name}'.format(uuid=client_uuid, name=role_name) + else: + destination_url = 'http://keycloak.url/auth/admin/realms/master/roles/{name}'.format(name=role_name) + self.headers = {'Location': destination_url} + + +COMMON_CREATED_ROLE = {'name': 'role1', 'description': 'a really long description usefull\nfor admin', 'composite': False, 'attributes': {}} + + +def update_created_role(into_client, to_append=None): + created_role = COMMON_CREATED_ROLE.copy() + if into_client: + created_role.update({'clientRole': True, 'containerId': '11111111-1111-1111-1111-111111111111', 'id': 'cccccccc-1111-1111-1111-111111111111'}) + else: + created_role.update({'clientRole': False, 'containerId': 'master', 'id': 'ffffffff-1111-1111-1111-111111111111'}) + if to_append: + created_role.update(to_append) + return created_role + + +@pytest.fixture +def mock_create_role_urls(mocker): + create_role_urls = CONNECTION_DICT.copy() + create_role_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/roles': { + 'POST': CreatedUserMockResponse('role1') + }, + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/role1': { + 'GET': [ + raise_404('http://localhost:8080/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/role1'), + create_wrapper(json.dumps(update_created_role(into_client=True))) + ] + }, + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles':{ + 'POST': CreatedUserMockResponse('role1', '11111111-1111-1111-1111-111111111111') + }, + 'http://keycloak.url/auth/admin/realms/master/roles/role1': { + 'GET': [ + raise_404('http://keycloak.url/auth/admin/realms/master/roles/role1'), + create_wrapper(json.dumps(update_created_role(into_client=False))) + ], + 'PUT': None + }, + }) + + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), create_role_urls), + autospec=True + ) + + +@pytest.mark.parametrize('role_modifications', [ + {}, + {'client_id': 'client-with-role'}, + {'attributes': {'a': 1}} +], ids=['role in realm', 'role in client', 'create_attributes']) +def test_state_present_with_absent_role_should_create_it( + monkeypatch, role_modifications, mock_create_role_urls): + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'name': 'role1', + 'description': 'a really long description usefull\nfor admin', + } + arguments.update(role_modifications) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_role.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Role role1 has been created.' + + +@pytest.fixture +def mock_update_role_urls(mocker): + update_role_urls = CONNECTION_DICT.copy() + update_role_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/role1':{ + 'GET': [ + create_wrapper(json.dumps(update_created_role(into_client=True))), + create_wrapper(json.dumps(update_created_role( + into_client=True, to_append={'attributes': {'a': ["12"], 'b': ['test']}}))) + ], + 'PUT': None + }, + 'http://keycloak.url/auth/admin/realms/master/roles/role1': { + 'GET': [ + create_wrapper(json.dumps(update_created_role(into_client=False))), + create_wrapper(json.dumps(update_created_role( + into_client=False, to_append={'attributes': {'a': ["12"], 'b': ['test']}}))), + ], + 'PUT': None + }, + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/ffffffff-1111-1111-1111-111111111111':{ + 'GET': [ + create_wrapper(json.dumps(update_created_role(into_client=False))), + create_wrapper(json.dumps(update_created_role( + into_client=False, to_append={'attributes': {'a': ["12"], 'b': ['test']}}))), + ], + 'PUT': None + } + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), update_role_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments', [ + {'name': 'role1'}, + {'name': 'role1', 'client_id': 'client-with-role'}, + {'id': 'ffffffff-1111-1111-1111-111111111111'} +], ids=['role in realm identified by name', 'role in client identified by name', + 'role identified by id']) +def test_state_present_with_present_role_should_update_it(monkeypatch, extra_arguments, mock_update_role_urls): + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'description': 'change description', + 'attributes': {'a': ["12"], 'b': ['test']} + } + arguments.update(extra_arguments) + + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_role.run_module() + ansible_exit_json = exec_error.value.args[0] + role_identifier = list(extra_arguments.values())[0] + assert ansible_exit_json['msg'] == 'Role %s has been updated.' % (role_identifier) From 7d9e325fec90b0a710efb1a7194d563d3d42018f Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Wed, 6 Feb 2019 16:56:08 +0100 Subject: [PATCH 03/79] Add new module managing keycloak user --- lib/ansible/module_utils/keycloak.py | 181 +++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index 9378a4f6981237..f0cc4b3e493161 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -54,6 +54,9 @@ URL_USERS = "{url}/admin/realms/{realm}/users" URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" + def keycloak_argument_spec(): """ @@ -758,3 +761,181 @@ def update_role(self, role_id, role_representation, realm="master", client_uuid= role_representation=role_representation, role_url=role_url ) + + def get_users(self, realm='master', filter=None): + """ Obtains client representations for clients in a realm + + :param realm: realm to be queried + :param filter: if defined, only the user with userid specified in the filter is returned + :return: list of dicts of users representations + """ + userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) + if filter is not None: + userlist_url += '?userId=%s' % filter + + try: + user_json = json.load( + open_url(userlist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain list of clients for realm %s: %s' + % (realm, str(e))) + + def get_user_by_id(self, id, realm='master'): + """ Obtain client template representation by id + + :param id: id (not name) of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + url = URL_USER.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.load( + open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + + def get_user_id(self, name, realm='master'): + """ Obtain user id by name + + :param name: name of user to be queried + :param realm: client template from this realm + :return: user id (usually a UUID) + """ + result = self.get_user_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def get_user_by_name(self, name, realm='master'): + """ Obtain user representation by name + + :param name: name of user to be queried + :param realm: user from this realm + :return: dict of user representation or None if none matching exist + """ + result = self.get_users(realm) + if isinstance(result, list): + result = [x for x in result if x['username'] == name] + if len(result) > 0: + return result[0] + return None + + def create_user(self, user_representation, realm="master"): + """ Create a user in keycloak + + :param user_representation: user representation of user to be created. Must at least contain field userId + :param realm: realm for user to be created + :return: HTTPResponse object on success + """ + # Keycloak wait username as key for the keycloak user username. For the + # keycloak modules, the username is an alias of the auth_username, thus + # cannot be used for the users. + try: + user_name = user_representation.pop('keycloakUsername') + except KeyError: + self.module.fail_json( + msg='User name needs to be specified when creating a new user', + user_representation=user_representation + ) + else: + user_representation.update({'username': user_name}) + user_url = URL_USERS.format(url=self.baseurl, realm=realm) + + try: + return open_url(user_url, method='POST', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not create user %s in realm %s: %s' + % (user_representation['username'], realm, str(e)), + payload=user_representation) + + def update_user(self, uuid, user_representation, realm="master"): + """ Update an existing user + :param uuid: id of user to be updated in Keycloak + :param user_representation: corresponding (partial/full) user representation with updates + :param realm: realm the user is in + :return: HTTPResponse object on success + """ + # Keycloak response with an error 409 conflict if a username is send + # when updating an user. To avoid this, if the user was designated by + # its username, it is deleted. + try: + user_representation.pop('keycloakUsername') + except KeyError: + pass + try: + keycloak_attributes = user_representation.pop('keycloakAttributes') + except KeyError: + pass + else: + user_representation.update({'attributes': keycloak_attributes}) + + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) + + try: + return open_url(user_url, method='PUT', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update user %s in realm %s: %s' % ( + uuid, realm, str(e)), + user_representation=user_representation, + user_url=user_url + ) + + def delete_user(self, id, realm="master"): + """ Delete a user from Keycloak + + :param id: id (not userId) of user to be deleted + :param realm: realm of user to be deleted + :return: HTTPResponse object on success + """ + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(user_url, method='DELETE', + headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not delete user %s in realm %s: %s' + % (id, realm, str(e))) + + def get_json_from_url(self, url): + try: + user_json = json.load( + open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to get: %s' % ( + url)) + except Exception as e: + self.module.fail_json(msg='Could not obtain url: %s' % (url)) From 65af8dc47eda20e56eeb20eb2c81e4dcf7e1d4ec Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 18 Apr 2019 11:47:24 +0200 Subject: [PATCH 04/79] Create module mapping role and group in keycloak --- lib/ansible/module_utils/keycloak.py | 63 ++++ .../keycloak/keycloak_group_role_mapping.py | 302 +++++++++++++++ .../keycloak/test_group_role_mapping.py | 357 ++++++++++++++++++ .../identity/keycloak/test_keycloak_role.py | 2 +- 4 files changed, 723 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py create mode 100644 test/units/modules/identity/keycloak/test_group_role_mapping.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index f0cc4b3e493161..13c8971ea77254 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -50,6 +50,10 @@ URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" URL_GROUPS = "{url}/admin/realms/{realm}/groups" URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_EFFECTIVE_REALM_ROLE_IN_GROUP = "{url}/admin/realms/{realm}/groups/{group_id}/role-mappings/realm/composite" +URL_REALM_ROLE_IN_GROUP = "{url}/admin/realms/{realm}/groups/{group_id}/role-mappings/realm/" +URL_EFFECTIVE_CLIENT_ROLE_IN_GROUP = "{url}/admin/realms/{realm}/groups/{group_id}/role-mappings/clients/{client_uuid}/composite" +URL_CLIENT_ROLE_IN_GROUP = "{url}/admin/realms/{realm}/groups/{group_id}/role-mappings/clients/{client_uuid}" URL_USERS = "{url}/admin/realms/{realm}/users" URL_USER = "{url}/admin/realms/{realm}/users/{id}" @@ -418,6 +422,65 @@ def get_group_by_name(self, name, realm="master"): self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" % (name, realm, str(e))) + def get_realm_roles_of_group(self, group_uuid, realm='master'): + effective_role_url = URL_EFFECTIVE_REALM_ROLE_IN_GROUP.format( + url=self.baseurl, + group_id=group_uuid, + realm=realm, + ) + try: + return json.load(open_url(url=effective_role_url, method="GET", + headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json( + msg="Could not fetch group role %s in realm %s: %s" % (group_uuid, realm, str(e))) + + def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): + effective_role_url = URL_EFFECTIVE_CLIENT_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid, + client_uuid=client_uuid, + ) + try: + return json.load(open_url(url=effective_role_url, method="GET", + headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json( + msg="Could not fetch group role %s for client %s in realm %s: %s" % ( + group_uuid, client_uuid, realm, str(e))) + + def create_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): + self._modify_link_between_group_and_role('POST', group_uuid, role, client_uuid, realm) + + def delete_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): + self._modify_link_between_group_and_role('DELETE', group_uuid, role, client_uuid, realm) + + def _modify_link_between_group_and_role( + self, method, group_uuid, role, client_uuid=None, realm='master'): + if client_uuid: + url = URL_CLIENT_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid, + client_uuid=client_uuid + ) + else: + url = URL_REALM_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid + ) + try: + return open_url(url=url, method=method, headers=self.restheaders, + validate_certs=self.validate_certs, data=json.dumps([role])) + except Exception as e: + self.module.fail_json( + msg="Could not link {} and {}".format(group_uuid, role['id']) + ) + def create_group(self, grouprep, realm="master"): """ Create a Keycloak group. diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py new file mode 100644 index 00000000000000..e878a8ce28a679 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py @@ -0,0 +1,302 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Nicolas Duclert +# 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 + + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = r''' +--- +module: keycloak_group_role_mapping + +short_description: Allows administration of Keycloak mapping between group and role via Keycloak API + +version_added: "2.9" + +description: + - This module allows the administration of Keycloak clients 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/4.8/rest-api/index.html/). + Aliases are provided so camelCased versions can be used as well. If they are in conflict + with ansible names or previous used names, they will be prefixed by "keycloak". + - The group, role and client should exist before the call to this module. If not, + a error message will be return. + +options: + state: + description: + - State of the mapping + - On C(present), the mapping between role and group will be created if it not exists. + - On C(absent), the mapping between role and group will be removed if it exists + type: str + choices: [ present, absent ] + default: present + + realm: + description: + - The realm where the role, group and optionaly client are. + type: str + default: master + + group_name: + description: + - Name of the group + - This parameter is mutually exclusive with group_id and one of + them is required by the module. + aliases: [ groupName ] + type: str + + group_id: + description: + - Id (as a uuid) of the group + - This parameter is mutually exclusive with group_name and one of + them is required by the module. + aliases: [ groupId ] + type: str + + role_name: + description: + - Name of the role + - This parameter is mutually exclusive with role_id and one of + them is required by the module. + aliases: [ roleName ] + type: str + + role_id: + description: + - Id (as a uuid) of the role + - This parameter is mutually exclusive with role_name and one of + them is required by the module. + aliases: [ roleId ] + type: str + + client_id: + description: + - Client id of client where the given role will be search. This is + usually an alphanumeric name chosen by you. + type: str + aliases: [ clientId ] + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = r''' +- name: create the mapping if it does not exist + keycloak_group_role_mapping: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + group_name: one_group + role_name: one_role +- name delete the mapping if it exists + keycloak_group_role_mapping: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: absent + group_name: one_group + role_name: one_role +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Link between one_group and one_role created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +roles_in_group: + description: the mapped role if it exists. + returned: always + type: dict + sample: { + 'name': 'already_link_role', + 'id': 'ffffffff-1111-1111-1111-111111111111', + 'description': 'showing why this role exists', + } +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible.module_utils._text import to_text +from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(type='str', default='present', + choices=['present', 'absent']), + realm=dict(type='str', default='master'), + group_name=dict(type='str', aliases=['groupName']), + group_id=dict(type='str', aliases=['groupId']), + role_name=dict(type='str', aliases=['roleName']), + role_id=dict(type='str', aliases=['roleId']), + client_id=dict(type='str', aliases=['clientId'], required=False), + ) + + argument_spec.update(meta_args) + + # The id of the role is unique in keycloak and if it is given the + # client_id is not used. In order to avoid confusion, I set a mutual + # exclusion. + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[ + ['group_name', 'group_id'], + ['role_name', 'role_id'], + ], + mutually_exclusive=[ + ['group_name', 'groupd_id'], + ['id', 'client_id'], + ['role_name', 'role_id'], + ], + ) + realm = module.params.get('realm') + state = module.params.get('state') + result = {} + kc = KeycloakAPI(module) + + given_role_id = {'name': module.params.get('role_name')} + if not given_role_id['name']: + given_role_id.update({'uuid': module.params.get('role_id')}) + given_role_id.pop('name') + client_id = module.params.get('client_id') + + group_name = module.params.get('group_name') + if group_name: + existing_group = kc.get_group_by_name(group_name, realm) + given_group_id = group_name + else: + group_uuid = module.params.get('group_id') + existing_group = kc.get_group_by_groupid(group_uuid, realm) + given_group_id = group_uuid + try: + group_uuid = existing_group['id'] + except TypeError: + module.fail_json(msg='group {} not found.'.format(given_group_id)) + + if client_id: + client_uuid = kc.get_client_id(client_id, realm) + if not client_uuid: + module.fail_json(msg='client {} not found.'.format(client_id)) + existing_roles = kc.get_client_roles_of_group(group_uuid, client_uuid, realm) + else: + client_uuid = None + existing_roles = kc.get_realm_roles_of_group(group_uuid, realm) + existing_role_uuid = [role['id'] for role in existing_roles] + + existing_role = kc.get_role(given_role_id, realm, client_uuid=client_uuid) + try: + role_uuid = existing_role['id'] + except TypeError: + if client_id: + module.fail_json(msg='role {} not found in {}.'.format( + list(given_role_id.values())[0], client_id)) + module.fail_json(msg='role {} not found.'.format( + list(given_role_id.values())[0])) + + if state == 'absent': + if role_uuid not in existing_role_uuid: + if client_id: + result['msg'] = to_text( + 'Links between {group_id} and {role_id} in {client_id} does_not_exist, ' + 'doing nothing.'.format( + group_id=given_group_id, + role_id=list(given_role_id.values())[0], + client_id=client_id + )) + else: + result['msg'] = to_text( + 'Links between {group_id} and {role_id} does not exist, doing nothing.'.format( + group_id=given_group_id, + role_id=list(given_role_id.values())[0] + ) + ) + result['changed'] = False + else: + kc.delete_link_between_group_and_role(group_uuid, existing_role, client_uuid, realm) + if client_id: + result['msg'] = 'Links between {group_id} and {role_id} in {client_id} deleted.'.format( + group_id=given_group_id, + role_id=list(given_role_id.values())[0], + client_id=client_id + ) + else: + result['msg'] = 'Links between {group_id} and {role_id} deleted.'.format( + group_id=given_group_id, + role_id=list(given_role_id.values())[0], + ) + result['changed'] = True + result['roles_in_group'] = {} + else: + if role_uuid not in existing_role_uuid: + kc.create_link_between_group_and_role(group_uuid, existing_role, client_uuid, realm) + if client_uuid: + result['msg'] = to_text('Link between {} and {} in {} created.'.format( + given_group_id, + list(given_role_id.values())[0], + client_id, + )) + updated_roles = kc.get_client_roles_of_group(group_uuid, client_uuid, realm) + else: + result['msg'] = to_text('Link between {} and {} created.'.format( + given_group_id, + list(given_role_id.values())[0], + )) + updated_roles = kc.get_realm_roles_of_group(group_uuid, realm) + result['changed'] = True + for role in updated_roles: + if role['id'] == role_uuid: + result['roles_in_group'] = role + else: + if client_id: + result['msg'] = 'Links between {} and {} in {} exists, doing nothing.'.format( + given_group_id, + list(given_role_id.values())[0], + client_id, + ) + else: + result['msg'] = 'Links between {} and {} exists, doing nothing.'.format( + given_group_id, + list(given_role_id.values())[0], + ) + result['changed'] = False + result['roles_in_group'] = existing_role + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_group_role_mapping.py b/test/units/modules/identity/keycloak/test_group_role_mapping.py new file mode 100644 index 00000000000000..bc5313d337e473 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_group_role_mapping.py @@ -0,0 +1,357 @@ +# -*- coding: utf-8 -*- +from __future__ import (absolute_import, division, print_function) + +import pytest +from itertools import count +import json + +from ansible.module_utils.six import StringIO +from units.modules.utils import ( + AnsibleExitJson, AnsibleFailJson, fail_json, exit_json, set_module_args) +from ansible.modules.identity.keycloak import keycloak_group_role_mapping +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def raise_404(url): + def _raise_404(): + raise HTTPError(url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('')) + return _raise_404 + + +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 build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + try: + url = args[0] + except IndexError: + url = kwargs['url'] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + return _mocked_requests + + +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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def raise_404(url): + def _raise_404(): + raise HTTPError(url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('')) + return _raise_404 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}'), + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps([{'id': '111-111', 'name': 'one_group'}])), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111': create_wrapper( + json.dumps({'id': '111-111', 'name': 'one_group'})), + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=one_client': create_wrapper( + json.dumps([{'id': '333-333', 'clientId': 'one_client'}])), +} + + +@pytest.fixture +def mock_doing_nothing_urls(mocker): + doing_nothing_urls = CONNECTION_DICT.copy() + doing_nothing_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/composite': create_wrapper( + json.dumps({})), + 'http://keycloak.url/auth/admin/realms/master/roles/one_role': create_wrapper( + json.dumps({'id': '222-222', 'name': 'one_role'})), + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/role_in_client': create_wrapper( + json.dumps({'id': '444-444', 'name': 'role_in_client'})), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/clients/333-333/composite': create_wrapper( + json.dumps([])), + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), doing_nothing_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments, waited_message', [ + ({'role_name': 'one_role'}, + 'Links between one_group and one_role does not exist, doing nothing.'), + ({'role_name': 'role_in_client', 'client_id': 'one_client'}, + 'Links between one_group and role_in_client in one_client does_not_exist, doing nothing.') +], ids=['role in realm master', 'role in client']) +def test_state_absent_without_link_should_not_do_something( + monkeypatch, extra_arguments, waited_message, mock_doing_nothing_urls): + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + 'group_name': 'one_group', + } + arguments.update(extra_arguments) + + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_group_role_mapping.main() + ansible_exit_json = exec_trace.value.args[0] + assert not ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == waited_message + assert ansible_exit_json['roles_in_group'] == {} + + +@pytest.fixture +def mock_creation_url(mocker): + creation_urls = CONNECTION_DICT.copy() + creation_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps([{'id': '555-555', 'name': 'to_link'}])), + 'http://keycloak.url/auth/admin/realms/master/groups/555-555': create_wrapper( + json.dumps({'id': '555-555', 'name': 'to_link'})), + 'http://keycloak.url/auth/admin/realms/master/groups/555-555/role-mappings/realm/composite': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps([{'id': '222-222', 'name': 'one_role'}])) + ], + 'http://keycloak.url/auth/admin/realms/master/roles/one_role': create_wrapper( + json.dumps({'id': '222-222', 'name': 'one_role'})), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/': None, + 'http://keycloak.url/auth/admin/realms/master/groups/555-555/role-mappings/clients/333-333/composite': [ + create_wrapper(json.dumps(({}))), + create_wrapper(json.dumps([{'id': 'b4af56e4-869a-44de-97b5-10c7d1bb9664', 'name': 'role_to_link_in_client'}])) + ], + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/role_to_link_in_client': create_wrapper( + json.dumps({'id': 'b4af56e4-869a-44de-97b5-10c7d1bb9664', 'name': 'role_to_link_in_client'}) + ), + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/222-222': create_wrapper( + json.dumps({'id': '222-222', 'name': 'one_role'})), + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), creation_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments, waited_message', [ + ({'group_name': 'to_link', 'role_name': 'one_role'}, 'Link between to_link and one_role created.'), + ({'group_name': 'to_link', 'role_name': 'role_to_link_in_client', 'client_id': 'one_client'}, + 'Link between to_link and role_to_link_in_client in one_client created.'), + ({'group_id': '555-555', 'role_id': '222-222'}, + 'Link between 555-555 and 222-222 created.') +], ids=['with name in realm', 'with name one client', 'with uuid for groups and roles']) +def test_state_present_without_link_should_create_link( + monkeypatch, extra_arguments, waited_message, mock_creation_url): + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_group_role_mapping.main() + ansible_exit_json = exec_trace.value.args[0] + assert ansible_exit_json['msg'] == waited_message + assert ansible_exit_json['changed'] + if 'role_name' in extra_arguments: + assert ansible_exit_json['roles_in_group']['name'] == extra_arguments['role_name'] + else: + assert ansible_exit_json['roles_in_group']['id'] == extra_arguments['role_id'] + + +@pytest.fixture() +def existing_nothing_to_do(mocker): + nothing_to_do_url = CONNECTION_DICT.copy() + nothing_to_do_url.update({ + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/composite': create_wrapper( + json.dumps([{'id': '456-456', 'name': 'already_link_role'}, ]) + ), + 'http://keycloak.url/auth/admin/realms/master/roles/already_link_role': create_wrapper( + json.dumps({'id': '456-456', 'name': 'already_link_role'}) + ), + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/role_in_client': create_wrapper( + json.dumps({'id': '789-789', 'name': 'already_link_role'})), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/clients/333-333/composite': create_wrapper( + json.dumps([{'id': '789-789', 'name': 'already_link_role', }, ]) + ), + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/already_link_role': create_wrapper( + json.dumps({'id': '789-789', 'name': 'already_link_role', }) + ), + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), nothing_to_do_url), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments, waited_message', [ + ({'group_name': 'one_group', 'role_name': 'already_link_role'}, + 'Links between one_group and already_link_role exists, doing nothing.'), + ({'group_name': 'one_group', 'role_name': 'already_link_role', 'client_id': 'one_client'}, + 'Links between one_group and already_link_role in one_client exists, doing nothing.') +], ids=['role in master', 'role in client']) +def test_state_present_with_link_should_no_do_something( + monkeypatch, extra_arguments, waited_message, existing_nothing_to_do +): + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_group_role_mapping.main() + ansible_exit_json = exec_trace.value.args[0] + assert not ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == waited_message + assert ansible_exit_json['roles_in_group']['name'] == extra_arguments['role_name'] + + +@pytest.fixture() +def to_delete(mocker): + delete_urls = CONNECTION_DICT.copy() + delete_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/composite': create_wrapper( + json.dumps([{'id': '987-987', 'name': 'to_unlink'}, ]) + ), + 'http://keycloak.url/auth/admin/realms/master/roles/to_unlink': create_wrapper( + json.dumps({'id': '987-987', 'name': 'to_unlink', }) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/': create_wrapper( + json.dumps({}) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/clients/333-333/composite': create_wrapper( + json.dumps([{'id': '765', '765': 'to_unlink'}, ]) + ), + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/to_unlink': create_wrapper( + json.dumps({'id': '765', '765': 'to_unlink'}) + ) + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), delete_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments, waited_message', [ + ({'group_name': 'one_group', 'role_name': 'to_unlink'}, + 'Links between one_group and to_unlink deleted.'), + ({'group_name': 'one_group', 'role_name': 'to_unlink', 'client_id': 'one_client'}, + 'Links between one_group and to_unlink in one_client deleted.') +], ids=['role in master', 'role in client']) +def test_state_absent_with_existing_should_delete_the_link( + monkeypatch, extra_arguments, waited_message, to_delete): + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_group_role_mapping.main() + ansible_exit_json = exec_trace.value.args[0] + assert ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == waited_message + assert not ansible_exit_json['roles_in_group'] + + +@pytest.fixture +def wrong_parameter_url(mocker): + wrong_parameter_urls = CONNECTION_DICT.copy() + wrong_parameter_urls.update({ + 'http://keycloak.url/auth/admin/realms/master/groups/000-000': raise_404('groups/000-000'), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/realm/composite': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/roles/doesnotexist': raise_404('roles/doesnotexist'), + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/000-000': raise_404('roles-by-id/000-000'), + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=doesnotexist': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/111-111/role-mappings/clients/333-333/composite': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/doesnotexist': raise_404('333-333/roles/doesnotexist'), + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), wrong_parameter_urls), + autospec=True + ) + + +@pytest.mark.parametrize('extra_arguments, waited_message', [ + ({'group_name': 'doesnotexist', 'role_name': 'one_role'}, + 'group doesnotexist not found.'), + ({'group_id': '000-000', 'role_name': 'one_role'}, + 'group 000-000 not found.'), + ({'group_name': 'one_group', 'role_name': 'doesnotexist'}, + 'role doesnotexist not found.'), + ({'group_name': 'one_group', 'role_id': '000-000'}, + 'role 000-000 not found.'), + ({'group_name': 'one_group', 'role_name': 'one_role', 'client_id': 'doesnotexist'}, + 'client doesnotexist not found.'), + ({'group_name': 'one_group', 'role_name': 'doesnotexist', 'client_id': 'one_client'}, + 'role doesnotexist not found in one_client.'), +], ids=['group name', 'group id', 'role name', 'role id', 'client name', + 'role name in client']) +def test_with_wrong_parameters(monkeypatch, extra_arguments, waited_message, wrong_parameter_url): + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_group_role_mapping.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as exec_trace: + keycloak_group_role_mapping.main() + ansible_exit_json = exec_trace.value.args[0] + assert ansible_exit_json['msg'] == waited_message diff --git a/test/units/modules/identity/keycloak/test_keycloak_role.py b/test/units/modules/identity/keycloak/test_keycloak_role.py index c73d027d639671..6c24bcab9f0408 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_role.py +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -146,7 +146,7 @@ def test_mutually_exclusive_arguments_should_raise_an_error(monkeypatch, mutual_ ansible_exit_json = exec_error.value.args[0] assert ansible_exit_json['msg'] == 'parameters are mutually exclusive: %s' % ( - to_text(', '.join(mutual_exclusive.keys()))) + to_text('|'.join(mutual_exclusive.keys()))) @pytest.mark.parametrize('role_identifier,error_message', [ From b62f6252b9ce9c2fd6fb74e913d153b8d1dad057 Mon Sep 17 00:00:00 2001 From: ndclt Date: Wed, 15 May 2019 16:11:14 +0200 Subject: [PATCH 05/79] style: pep8 role --- .../identity/keycloak/keycloak_role.py | 19 +++++++++---------- .../identity/keycloak/test_keycloak_role.py | 11 ++++++----- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_role.py b/lib/ansible/modules/identity/keycloak/keycloak_role.py index 9322c17f875ed7..aa99b5102f22a1 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_role.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -41,13 +41,13 @@ type: str choices: [present, absent] default: present - + realm: description: - The realm to create the client in. type: str default: master - + attributes: description: – a dictionary with the key and the value to put in keycloak. @@ -55,30 +55,30 @@ Keys and values are converted into string. type: dict required: false - + name: description: - the name of the role to modify. - I(name) and I(id) are mutually exclusive. type: str - + id: description: - the id (generally an uuid) of the role to modify. - I(name) and I(id) are mutually exclusive. - I(id) and I(client_id) are mutually exclusive. type: str - + client_id: description: - - client id of client where the role will be inserted. This is usually + - client id of client where the role will be inserted. This is usually an alphanumeric name chosen by you. - the client must exist before this call. - I(id) and I(client_id) are mutually exclusive. type: str aliases: [ clientId ] required: false - + description: description: - The description associate to your role. @@ -89,7 +89,6 @@ - keycloak author: - Nicolas Duclert (@ndclt) - ''' EXAMPLES = r''' @@ -113,7 +112,7 @@ realm: master client_id: client-with-role name: role-test-in-client-1 - + - name: create or update keycloak role in realm (with everything) keycloak_role: auth_client_id: admin-cli @@ -134,7 +133,7 @@ returned: always type: str sample: "Role role-test has been updated" - + proposed: description: role representation of proposed changes to role returned: always diff --git a/test/units/modules/identity/keycloak/test_keycloak_role.py b/test/units/modules/identity/keycloak/test_keycloak_role.py index 6c24bcab9f0408..e4cc038733505e 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_role.py +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -24,7 +24,7 @@ def _create_wrapper(): CONNECTION_DICT = { - 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "a long token"}'),} + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "a long token"}'), } DEFAULT_ROLES = [ {'id': 'c02533c5-d943-4274-9953-8b6a930ee74e', 'name': 'admin', @@ -196,7 +196,8 @@ def mock_delete_role_urls(mocker): delete_role_urls.update({ 'http://keycloak.url/auth/admin/realms/master/roles/to%20delete': create_wrapper(json.dumps(to_delete_role_in_master)), 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), - 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/to%20delete': create_wrapper(json.dumps(to_delete_role_in_client)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/to%20delete': create_wrapper( + json.dumps(to_delete_role_in_client)), 'http://keycloak.url/auth/admin/realms/master/roles-by-id/cccccccc-d943-4274-9953-8b6a930ee74e': None, 'http://keycloak.url/auth/admin/realms/master/roles-by-id/bbbbbbbb-acca-463b-bd93-2e7fd66022f6': { 'DELETE': None, @@ -275,7 +276,7 @@ def mock_create_role_urls(mocker): create_wrapper(json.dumps(update_created_role(into_client=True))) ] }, - 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles':{ + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles': { 'POST': CreatedUserMockResponse('role1', '11111111-1111-1111-1111-111111111111') }, 'http://keycloak.url/auth/admin/realms/master/roles/role1': { @@ -327,7 +328,7 @@ def mock_update_role_urls(mocker): update_role_urls = CONNECTION_DICT.copy() update_role_urls.update({ 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), - 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/role1':{ + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/role1': { 'GET': [ create_wrapper(json.dumps(update_created_role(into_client=True))), create_wrapper(json.dumps(update_created_role( @@ -343,7 +344,7 @@ def mock_update_role_urls(mocker): ], 'PUT': None }, - 'http://keycloak.url/auth/admin/realms/master/roles-by-id/ffffffff-1111-1111-1111-111111111111':{ + 'http://keycloak.url/auth/admin/realms/master/roles-by-id/ffffffff-1111-1111-1111-111111111111': { 'GET': [ create_wrapper(json.dumps(update_created_role(into_client=False))), create_wrapper(json.dumps(update_created_role( From 357e3ccbee07ce2fca19a78e81a262bbd444c33d Mon Sep 17 00:00:00 2001 From: Eike Frost Date: Wed, 13 Dec 2017 17:09:07 +0100 Subject: [PATCH 06/79] keycloak_scopes for Keycloak scope mapping configuration via ansible --- lib/ansible/module_utils/keycloak.py | 810 ++++++++++++++++++ .../identity/keycloak/keycloak_client.py | 1 - .../keycloak/keycloak_client_scope.py | 473 ++++++++++ 3 files changed, 1283 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_client_scope.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index 13c8971ea77254..ec7e964024385b 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -61,6 +61,12 @@ URL_USERS = "{url}/admin/realms/{realm}/users" URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_CLIENT_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings" +URL_CLIENT_SCOPE_MAPPINGS_CLIENT = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/clients/{client}" +URL_CLIENT_SCOPE_MAPPINGS_CLIENT_AVAILABLE = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/clients/{client}/available" +URL_CLIENT_SCOPE_MAPPINGS_REALM = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/realm" +URL_CLIENT_SCOPE_MAPPINGS_REALM_AVAILABLE = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/realm/available" + def keycloak_argument_spec(): """ @@ -83,6 +89,43 @@ def camel(words): return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) +def check_role_representation(rep): + """ + This checks whether a given dict is a valid role representation and provides error messages if not. + + :param rep: role representation / specification to be checked + :return: Boolean for success, and message if an error occurred as a string. + """ + + valid_role_params = ['clientRole', 'composite', 'composites', 'containerId', 'description', + 'id', 'name', 'scopeParamRequired'] + + if not isinstance(rep, dict): + return False, 'Roles must be defined as dicts, "%s" is not one.' % rep + + invalid_role_params = [k for k in rep.keys() if k not in valid_role_params] + if any(invalid_role_params): + return False, 'Role specification invalid; "%s" are not one of %s' % (invalid_role_params, valid_role_params) + + if rep.get('composites'): + if not isinstance(rep['composites'], dict): + return False, 'Role specification invalid, composites must be a dict, "%s" is not.' % rep['composites'] + invalid_composite_specs = [k for k in rep['composites'] if k not in ['client', 'realm']] + if any(invalid_composite_specs): + return False, 'Role specification invalid, composites can only consist of realm and client role lists.' + for c in rep['composites'].keys(): + if c == 'client': + if not isinstance(rep['composites'][c], dict): + return False, 'Composite role specification invalid, client roles must be defined as a dict, "%s" is not one' % rep['composites'][c] + for v in rep['composites'][c].values(): + if not isinstance(v, list): + return False, 'Composite role specification invalid, client roles must be a list, "%s" is not one' % v + elif c == 'realm': + if not isinstance(rep['composites'][c], list): + return False, 'Composite role specification invalid, relam roles must be a list, "%s" is not one' % rep['composites'][c] + return True, '' + + class KeycloakAPI(object): """ Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which is obtained through OpenID connect @@ -357,6 +400,773 @@ def delete_client_template(self, id, realm="master"): self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) + def get_scope_mappings(self, id, target='client', realm='master'): + """ + Get scope mappings for client or client template + + :param id: id of client or client template + :param target: either client or client-template + :param realm: realm of the client or client template + :return: dict of mappings with keys 'clientMappings' and 'realmMappings' + """ + url = URL_CLIENT_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=id, target=target) + + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mappings for %s in realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain scope mappings for %s in realm %s: %s' + % (id, realm, str(e))) + + def get_scope_mapping(self, id, id_client=None, target='client', realm='master'): + """ + Get client scope mappings for a client or client template + + :param id: id of client or client template for the scopes + :param id_client: id (not client_id) of the client whose client roles are to be queried; if this is not given, get realm roles instead + :param target: either 'client' or 'client-template' + :param realm: realm for the client or client template this is for + :return: list of role representations + """ + if id_client is not None: + url = URL_CLIENT_SCOPE_MAPPINGS_CLIENT.format(url=self.baseurl, realm=realm, id=id, + client=id_client, target=target) + else: + url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, + target=target) + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mapping for %s in realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain scope mapping for %s in realm %s: %s' + % (id, realm, str(e))) + + def get_available_roles(self, id, id_client=None, target='client', realm='master'): + """ + Get roles available to be attached to a client or client template's scope + + :param id: id of client or client template for the scopes + :param id_client: id (not client_id) of the client whose available client roles are to be queried (if this is not set, get realm roles instead) + :param target: either 'client' or 'client-template' + :param realm: realm for the client or client template this is for + :return: list of role representations + """ + if id_client is not None: + url = URL_CLIENT_SCOPE_MAPPINGS_CLIENT_AVAILABLE.format(url=self.baseurl, realm=realm, + id=id, client=id_client, + target=target) + else: + url = URL_CLIENT_SCOPE_MAPPINGS_REALM_AVAILABLE.format(url=self.baseurl, realm=realm, + id=id, target=target) + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain available roles for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain scope mappings for %s in realm %s: %s' + % (id, realm, str(e))) + + def create_scope_mapping(self, id, roles, id_client=None, target='client', realm='master'): + """ + Creates or updates a scope mapping for a client or client template + + :param id: id of client or client template for the scopes + :param id_client: id (not client_id) of the client whose available client roles are to be queried (if this is not set, set realm roles instead) + :param role: list of role representations to be added to the scope + :param target: either 'client' or 'client-template' + :param realm: realm for the client or client template this is for + :return: HTTPResponse on success + """ + if id_client is not None: + url = URL_CLIENT_SCOPE_MAPPINGS_CLIENT.format(url=self.baseurl, realm=realm, id=id, + client=id_client, target=target) + else: + url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, + target=target) + try: + return open_url(url, method='POST', headers=self.restheaders, data=json.dumps(roles), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' + % (id, realm, str(e))) + + def delete_scope_mapping(self, id, roles, id_client=None, target='client', realm='master'): + """ + Deletes a scope mapping for a client or client template + + :param id: id of client or client template for the scopes + :param id_client: id (not client_id) of the client whose available client roles are to be queried (if this is not set, delete realm roles instead) + :param roles: list of role representations to be deleted from the scope + :param target: either 'client' or 'client-template' + :param realm: realm for the client or client template this is for + :return: list of role representations + """ + if id_client is not None: + url = URL_CLIENT_SCOPE_MAPPINGS_CLIENT.format(url=self.baseurl, realm=realm, id=id, + client=id_client, target=target) + else: + url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, + target=target) + try: + return open_url(url, method='DELETE', headers=self.restheaders, data=json.dumps(roles), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' + % (id, realm, str(e))) + def get_groups(self, realm="master"): + """ Fetch the name and ID of all groups on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_group_by_groupid, passing in the ID of the group you wish to return. + + :param realm: Return the groups of this realm (default "master"). + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" + % (realm, str(e))) + + def get_group_by_groupid(self, gid, realm="master"): + """ Fetch a keycloak group from the provided realm using the group's unique ID. + + If the group does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param gid: UUID of the group to be returned + :param realm: Realm in which the group resides; default 'master'. + """ + groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) + try: + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (gid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (gid, realm, str(e))) + + def get_group_by_name(self, name, realm="master"): + """ Fetch a keycloak group within a realm based on its name. + + The Keycloak API does not allow filtering of the Groups resource by name. + As a result, this method first retrieves the entire list of groups - name and ID - + then performs a second query to fetch the group. + + If the group does not exist, None is returned. + :param name: Name of the group to fetch. + :param realm: Realm in which the group resides; default 'master' + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + all_groups = self.get_groups(realm=realm) + + for group in all_groups: + if group['name'] == name: + return self.get_group_by_groupid(group['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (name, realm, str(e))) + + def get_realm_roles_of_group(self, group_uuid, realm='master'): + effective_role_url = URL_EFFECTIVE_REALM_ROLE_IN_GROUP.format( + url=self.baseurl, + group_id=group_uuid, + realm=realm, + ) + try: + return json.load(open_url(url=effective_role_url, method="GET", + headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json( + msg="Could not fetch group role %s in realm %s: %s" % (group_uuid, realm, str(e))) + + def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): + effective_role_url = URL_EFFECTIVE_CLIENT_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid, + client_uuid=client_uuid, + ) + try: + return json.load(open_url(url=effective_role_url, method="GET", + headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json( + msg="Could not fetch group role %s for client %s in realm %s: %s" % ( + group_uuid, client_uuid, realm, str(e))) + + def create_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): + self._modify_link_between_group_and_role('POST', group_uuid, role, client_uuid, realm) + + def delete_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): + self._modify_link_between_group_and_role('DELETE', group_uuid, role, client_uuid, realm) + + def _modify_link_between_group_and_role( + self, method, group_uuid, role, client_uuid=None, realm='master'): + if client_uuid: + url = URL_CLIENT_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid, + client_uuid=client_uuid + ) + else: + url = URL_REALM_ROLE_IN_GROUP.format( + url=self.baseurl, + realm=realm, + group_id=group_uuid + ) + try: + return open_url(url=url, method=method, headers=self.restheaders, + validate_certs=self.validate_certs, data=json.dumps([role])) + except Exception as e: + self.module.fail_json( + msg="Could not link {} and {}".format(group_uuid, role['id']) + ) + + def create_group(self, grouprep, realm="master"): + """ Create a Keycloak group. + + :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return open_url(groups_url, method='POST', headers=self.restheaders, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create group %s in realm %s: %s" + % (grouprep['name'], realm, str(e))) + + def update_group(self, grouprep, realm="master"): + """ Update an existing group. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) + + try: + return open_url(group_url, method='PUT', headers=self.restheaders, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update group %s in realm %s: %s' + % (grouprep['name'], realm, str(e))) + + def delete_group(self, name=None, groupid=None, realm="master"): + """ Delete a group. One of name or groupid must be provided. + + Providing the group ID is preferred as it avoids a second lookup to + convert a group name to an ID. + + :param name: The name of the group. A lookup will be performed to retrieve the group ID. + :param groupid: The ID of the group (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if groupid is None and name is None: + # prefer an exception since this is almost certainly a programming error in the module itself. + raise Exception("Unable to delete group - one of group ID or name must be provided.") + + # only lookup the name if groupid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if groupid is None and name is not None: + for group in self.get_groups(realm=realm): + if group['name'] == name: + groupid = group['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if groupid is None: + return None + + # should have a good groupid by here. + group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) + 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_users(self, realm='master', filter=None): + """ Obtains user representations for users in a realm + + :param realm: realm to be queried + :param filter: if defined, only the user with userid specified in the filter is returned + :return: list of dicts of users representations + """ + userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) + if filter is not None: + userlist_url += '?userId=%s' % filter + + try: + user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' + % (realm, str(e))) + + def get_user_by_id(self, id, realm='master'): + """ Obtain user representation by id + + :param id: id (not name) of user to be queried + :param realm: realm to be queried + :return: dict of user representation or None if none matching exists + """ + url = URL_USER.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.load( + open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + + def get_user_id(self, name, realm='master'): + """ Obtain user id by name + + :param name: name of user to be queried + :param realm: realm to be queried + :return: user id (usually a UUID) + """ + result = self.get_user_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def get_user_by_name(self, name, realm='master'): + """ Obtain user representation by name + + :param name: name of user to be queried + :param realm: user from this realm + :return: dict of user representation or None if none matching exist + """ + result = self.get_users(realm) + if isinstance(result, list): + result = [x for x in result if x['username'] == name] + if len(result) > 0: + return result[0] + return None + + def create_user(self, user_representation, realm="master"): + """ Create a user in keycloak + + :param user_representation: user representation of user to be created. Must at least contain field userId + :param realm: realm for user to be created + :return: HTTPResponse object on success + """ + # Keycloak wait username as key for the keycloak user username. For the + # keycloak modules, the username is an alias of the auth_username, thus + # cannot be used for the users. + try: + user_name = user_representation.pop('keycloakUsername') + except KeyError: + self.module.fail_json( + msg='User name needs to be specified when creating a new user', + user_representation=user_representation + ) + else: + user_representation.update({'username': user_name}) + user_url = URL_USERS.format(url=self.baseurl, realm=realm) + + try: + return open_url(user_url, method='POST', headers=self.restheaders, + data=json.dumps(user_representation), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create user %s in realm %s: %s' + % (user_representation['username'], realm, str(e)), + payload=user_representation) + + def update_user(self, uuid, user_representation, realm="master"): + """ Update an existing user + :param uuid: id of user to be updated in Keycloak + :param user_representation: corresponding (partial/full) user representation with updates + :param realm: realm the user is in + :return: HTTPResponse object on success + """ + # Keycloak response with an error 409 conflict if a username is send + # when updating an user. To avoid this, if the user was designated by + # its username, it is deleted. + try: + user_representation.pop('keycloakUsername') + except KeyError: + pass + try: + keycloak_attributes = user_representation.pop('keycloakAttributes') + except KeyError: + pass + else: + user_representation.update({'attributes': keycloak_attributes}) + + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) + + try: + return open_url(user_url, method='PUT', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update user %s in realm %s: %s' % (uuid, realm, str(e)), + user_representation=user_representation, + user_url=user_url + ) + + def delete_user(self, id, realm="master"): + """ Delete a user from Keycloak + + :param id: id (not userId) of user to be deleted + :param realm: realm of user to be deleted + :return: HTTPResponse object on success + """ + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(user_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete user %s in realm %s: %s' + % (id, realm, str(e))) + + def get_json_from_url(self, url): + try: + user_json = json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to get: %s' % (url)) + except Exception as e: + self.module.fail_json(msg='Could not obtain url: %s' % (url)) + + def get_role_url(self, role_id, realm='master', client_uuid=None): + if 'name' in role_id: + role_name = role_id['name'] + if client_uuid: + rolelist_url = URL_CLIENT_ROLE.format( + url=self.baseurl, realm=quote(realm), id=client_uuid, + role_id=quote(role_name)) + else: + rolelist_url = URL_REALM_ROLE.format( + url=self.baseurl, realm=quote(realm), role_id=quote(role_name)) + else: + rolelist_url = URL_REALM_ROLE_BY_ID.format( + url=self.baseurl, realm=realm, id=role_id['uuid']) + return rolelist_url + + def get_role(self, role_id, realm='master', client_uuid=None): + """ Obtain client template representation by id + :param role_id: id or name of role to be queried + :param realm: role from this realm + :return: dict of role representation or None if none matching exist + """ + role_url = self.get_role_url(role_id, realm, client_uuid) + + try: + return json.load( + open_url(role_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json( + msg='Could not obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain role %s for realm %s: %s' + % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) + + def delete_role(self, role_id, realm="master"): + """ Delete a role from Keycloak + :param role_id: id of role (uuid or name) to be deleted + :param realm: realm of role to be deleted + :return: HTTPResponse object on success + """ + role_url = URL_REALM_ROLE_BY_ID.format(url=self.baseurl, realm=quote(realm), id=quote(role_id)) + + 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='Could not delete role %s in realm %s: %s' + % (role_id, realm, to_text(e))) + + def get_role_id(self, name, realm='master', client_uuid=None): + """ Obtain role id by name + :param name: name of role to be queried + :param realm: realm to be queried + :return: role id (usually a UUID) + """ + result = self.get_role(name, realm, client_uuid) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def create_role(self, role_representation, realm="master", client_uuid=None): + """ Create a role in keycloak + :param role_representation: role representation to be created. + :param realm: realm for role to be created + :return: HTTPResponse object on success + """ + if client_uuid: + role_url = URL_CLIENT_ROLES.format( + url=self.baseurl, realm=quote(realm), id=client_uuid) + role_representation.pop('clientId') + else: + role_url = URL_REALM_ROLES.format(url=self.baseurl, realm=quote(realm)) + + try: + return open_url(role_url, method='POST', headers=self.restheaders, + data=json.dumps(role_representation), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not create role %s in realm %s: %s' + % (to_text(role_representation['name']), to_text(realm), to_text(e)), + payload=role_representation) + + def update_role(self, role_id, role_representation, realm="master", client_uuid=None): + """ Update an existing role + :param role_id: id of role to be updated in Keycloak + :param role_representation: corresponding (partial/full) role representation with updates + :param realm: realm the role is in + :return: HTTPResponse object on success + """ + role_url = self.get_role_url(role_id, realm, client_uuid) + + try: + return open_url(role_url, method='PUT', headers=self.restheaders, + data=json.dumps(role_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update role %s in realm %s: %s' % ( + to_text(list(role_id.values())[0]), to_text(realm), to_text(e)), + role_representation=role_representation, + role_url=role_url + ) + + def get_users(self, realm='master', filter=None): + """ Obtains client representations for clients in a realm + + :param realm: realm to be queried + :param filter: if defined, only the user with userid specified in the filter is returned + :return: list of dicts of users representations + """ + userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) + if filter is not None: + userlist_url += '?userId=%s' % filter + + try: + user_json = json.load( + open_url(userlist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain list of clients for realm %s: %s' + % (realm, str(e))) + + def get_user_by_id(self, id, realm='master'): + """ Obtain client template representation by id + + :param id: id (not name) of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + url = URL_USER.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.load( + open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json( + msg='Could not obtain user %s for realm %s: %s' + % (id, realm, str(e))) + + def get_user_id(self, name, realm='master'): + """ Obtain user id by name + + :param name: name of user to be queried + :param realm: client template from this realm + :return: user id (usually a UUID) + """ + result = self.get_user_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def get_user_by_name(self, name, realm='master'): + """ Obtain user representation by name + + :param name: name of user to be queried + :param realm: user from this realm + :return: dict of user representation or None if none matching exist + """ + result = self.get_users(realm) + if isinstance(result, list): + result = [x for x in result if x['username'] == name] + if len(result) > 0: + return result[0] + return None + + def create_user(self, user_representation, realm="master"): + """ Create a user in keycloak + + :param user_representation: user representation of user to be created. Must at least contain field userId + :param realm: realm for user to be created + :return: HTTPResponse object on success + """ + # Keycloak wait username as key for the keycloak user username. For the + # keycloak modules, the username is an alias of the auth_username, thus + # cannot be used for the users. + try: + user_name = user_representation.pop('keycloakUsername') + except KeyError: + self.module.fail_json( + msg='User name needs to be specified when creating a new user', + user_representation=user_representation + ) + else: + user_representation.update({'username': user_name}) + user_url = URL_USERS.format(url=self.baseurl, realm=realm) + + try: + return open_url(user_url, method='POST', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not create user %s in realm %s: %s' + % (user_representation['username'], realm, str(e)), + payload=user_representation) + + def update_user(self, uuid, user_representation, realm="master"): + """ Update an existing user + :param uuid: id of user to be updated in Keycloak + :param user_representation: corresponding (partial/full) user representation with updates + :param realm: realm the user is in + :return: HTTPResponse object on success + """ + # Keycloak response with an error 409 conflict if a username is send + # when updating an user. To avoid this, if the user was designated by + # its username, it is deleted. + try: + user_representation.pop('keycloakUsername') + except KeyError: + pass + try: + keycloak_attributes = user_representation.pop('keycloakAttributes') + except KeyError: + pass + else: + user_representation.update({'attributes': keycloak_attributes}) + + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) + + try: + return open_url(user_url, method='PUT', headers=self.restheaders, + data=json.dumps(user_representation), + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not update user %s in realm %s: %s' % ( + uuid, realm, str(e)), + user_representation=user_representation, + user_url=user_url + ) + + def delete_user(self, id, realm="master"): + """ Delete a user from Keycloak + + :param id: id (not userId) of user to be deleted + :param realm: realm of user to be deleted + :return: HTTPResponse object on success + """ + user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(user_url, method='DELETE', + headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json( + msg='Could not delete user %s in realm %s: %s' + % (id, realm, str(e))) + + def get_json_from_url(self, url): + try: + user_json = json.load( + open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return user_json + except ValueError as e: + self.module.fail_json( + msg='API returned incorrect JSON when trying to get: %s' % ( + url)) + except Exception as e: + self.module.fail_json(msg='Could not obtain url: %s' % (url)) + def get_groups(self, realm="master"): """ Fetch the name and ID of all groups on the Keycloak server. diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py index fe6984dae960cd..548fb9b55cc73c 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client.py @@ -668,7 +668,6 @@ def main(): meta_args = dict( state=dict(default='present', choices=['present', 'absent']), realm=dict(type='str', default='master'), - id=dict(type='str'), client_id=dict(type='str', aliases=['clientId']), name=dict(type='str'), diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py new file mode 100644 index 00000000000000..0eb2c9c049ff3b --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py @@ -0,0 +1,473 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: keycloak_client_scope + +short_description: Allows administration of Keycloak scope mappings via the Keycloak REST API + +version_added: "2.8" + +description: + - This module allows the administration of Keycloak scope mappings through 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. + +options: + state: + description: + - State of the scope mapping. + - On C(present), the scope mapping will be created or updated/extended. + - On C(absent), the scope mapping will be removed if it exists. + choices: ['present', 'absent'] + default: 'present' + + purge_roles: + description: + - Sets whether the scope mapping defined in this module is exclusive. If set to C(True), + removes all roles not explicitly listed from the scope. + type: bool + default: False + + target: + description: + - Scope mappings can be acted upon for either clients or client mappings. Choose one + here; the default ist C(client). + default: 'client' + choices: ['client', 'client-template'] + + id: + description: + - id of client or client template to work on scope mappings on. Either this or one of + I(client_id)/I(name) are required. + + name: + description: + - name of client template to work on (if I(target) is C('clienttemplate')). Either this + or I(id) is required. + + realm: + description: + - realm this scope mapping belongs to. + + client_id: + description: + - client_id of client to work on (if I(target) is C('client')). Either this or I(id) + is required. + + type: + description: + - Type of scope mapping. + - On C(realm), the scope mappings are for realm scopes. (In this case C(roles) is + required) + - On C(client), the scope mappings are for client scopes. (In this case C(clientroles) + is required) + choices: ['realm', 'client'] + default: 'realm' + + roles: + description: + - A list of role representations (in dict-form) to be acted on. Usually, only C(name) is + required. Either C(roles) or C(clientroles) is required, depending on C(type). + U(http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_rolerepresentation) can be + referenced for the structure. Roles need to exist already to be put in scopes. + suboptions: + name: + description: + - Name of the role + required: true + + clientRole: + description: + - Boolean specifying whether this is a clientRole + + composite: + description: + - Boolean specifying whether this is a composite role. If so, I(composites) + contains its definition. + + composites: + description: + - if I(composite) is set to True, this defines the composite role definition through + a dict. The set of all the rolenames specified therein is the set of roles this + composite role is comprised of. The dict contains the keys I(client) and I(realm). + - I(client) contains another dict with client names as keys and lists of client + role names as values. + - I(realm) contains a list of realm role names. + + containerId: + description: + - Container id of this role - usually the realmname. + + description: + description: + - Description of this role. + + id: + description: + - Unique id of this role. + + scopeParamRequired: + description: + - Boolean specifying whether this role is only granted when a scope parameter + with this role name is used during the auth/token request. + + clientroles: + description: + - A dict of lists of role representations (in dict-form) to be acted on. At minimum, + C(name) is required in a role representation. The dict's keys are the client-role + scopes for the attached roles representations. There is an example below. Either + C(roles) or C(clientroles) is required, depending on C(type). More information on + valid role representations is provided in the documentation for I(roles). Roles need + to exist already to be put in scopes. + + +extends_documentation_fragment: + - keycloak + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Add realm roles to a client scope mapping (using implicit default of acting on a client and setting realm scope mappings) + local_action: + module: keycloak_client_scope + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: testrealm + state: present + client_id: testclient + roles: + - name: testrole01 + - name: testrole02 + - name: testrole03 + +- name: Add client roles to a client-template scope mapping + local_action: + module: keycloak_client_scope + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: testrealm + target: client-template + state: present + type: client + name: testclienttemplate + clientroles: + testclient01: + - name: testrole01 + testclient02: + - name: testrole02 + +- name: Remove client role testrole01 for testclient01 from testclienttemplate + local_action: + module: keycloak_client_scope + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: testrealm + target: client-template + state: absent + type: client + name: testclienttemplate + clientroles: + testclient01: + - name: testrole01 + +- name: Remove all client roles from testclient01 using exclusive and an empty client role dict + local_action: + module: keycloak_client_scope + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: testrealm + state: present + purge_roles: True + type: client + client_id: testclient01 + clientroles: +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Added/Updated 0 scope mapping(s), removed 0 scope mapping(s)." + +proposed: + description: Proposed changes after evaluation by module + returned: always + type: dict + sample: { + "realm": [ + { + "id": "16538ff1-7a92-4cef-a31c-b52c3543d8e9", + "name": "testrole01" + }, + { + "id": "88663e7d-a16a-4e51-a476-4fe68e48549f", + "name": "testrole02" + }, + { + "id": "23e55337-0f17-4e76-88c1-81b29d8a46ae", + "name": "testrole03" + } + ] + } +existing: + description: Existing scope mappings on Keycloak's side before anything is changed + returned: always + type: dict + sample: { + "testclient01": [ + { + "clientRole": true, + "composite": false, + "containerId": "9442a1fb-2508-43f0-8392-e38354d66586", + "id": "1903be17-7a10-40f8-941f-902c45be036b", + "name": "testrole01", + "scopeParamRequired": false + } + ] + } +end_state: + description: State after changes have been applied, as seen by Keycloak + returned: always + type: dict + sample: { + "realm": [ + { + "clientRole": false, + "composite": false, + "containerId": "testrealm", + "id": "fc0bf800-d373-4c18-a5b4-4a53d4ff023c", + "name": "test_role", + "scopeParamRequired": false + } + ] + } +''' + +import json +from copy import deepcopy +from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec, check_role_representation +from ansible.module_utils.basic import AnsibleModule + + +def contains_role(roles, rolename): + """ + Checks whether a given role is included in a list of role-representations. + + :param roles: list of role representations + :param rolename: name of role to check for + :return: boolean + """ + if isinstance(roles, list): + for role in roles: + if role['name'] == rolename: + return True + return False + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + realm=dict(), + target=dict(default='client', choices=['client', 'client-template']), + id=dict(), + name=dict(), + client_id=dict(), + state=dict(default='present', choices=['present', 'absent']), + type=dict(default='realm', choices=['realm', 'client']), + roles=dict(type='list', elements='dict', default=list()), + clientroles=dict(type='dict', default=dict()), + purge_roles=dict(type='bool', default=False) + ) + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['id', 'name', 'client_id']], + required_if=[['type', 'realm', ['roles']], ['type', 'client', ['clientroles']]], + mutually_exclusive=[['roles', 'clientroles']]) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + kc = KeycloakAPI(module) + + # Initialize some general variables + realm = module.params.get('realm') + state = module.params.get('state') + target = module.params.get('target') + roletype = module.params.get('type') + roles = module.params.get('roles') + clientroles = module.params.get('clientroles') + purge_roles = module.params.get('purge_roles') + + for role in roles: + checkresult, msg = check_role_representation(role) + if not checkresult: + module.fail_json(msg=msg) + + for client in clientroles: + if isinstance(clientroles[client], list): + for role in clientroles[client]: + checkresult, msg = check_role_representation(role) + if not checkresult: + module.fail_json(msg=msg) + + # obtain ID of client or client template when given client_id or name + cid = module.params.get('id') + if cid is None: + if target == 'client': + if module.params.get('client_id') is not None: + cid = kc.get_client_id(module.params.get('client_id'), realm=realm) + else: + module.fail_json(msg='When target is client, you need to specify an id or client_id.') + else: + if module.params.get('name') is not None: + cid = kc.get_client_template_id(module.params.get('name'), realm=realm) + else: + module.fail_json(msg='When target is client-template, you need to specify an id or a name.') + if cid is None: + module.fail_json(msg='Could not obtain valid client(-template) id to work on.') + + if roletype == 'realm': + # in this case, the loop below needs to be run once with a None item + scope_clients = [None] + if roletype == 'client': + scope_clients = list(clientroles.keys()) + if purge_roles: + # when purge_roles is set, we want to delete already existing scope mappings regarding all + # clients, so add existing mappings' client-names to list of clients to iterate over + # (with an empty scope mapping) + currentmappings = kc.get_scope_mappings(cid, target=target, realm=realm) + if 'clientMappings' in currentmappings: + currentmappings = currentmappings['clientMappings'] + else: + currentmappings = dict() + + for mapping in currentmappings.keys(): + if mapping not in scope_clients: + scope_clients.append(mapping) + + # Let's do some bookkeeping + removed_roles = 0 + updated_roles = 0 + for scope_client in scope_clients: + if scope_client is not None: + # If this is a client scope mapping as opposed to a realm scope mapping + if scope_client in clientroles: + roles = clientroles[scope_client] + if roles is None: + roles = [] + else: + roles = [] + scope_client_id = kc.get_client_id(scope_client, realm=realm) + else: + scope_client_id = scope_client + + # obtain current roles + current = kc.get_scope_mapping(cid, id_client=scope_client_id, target=target, realm=realm) + proposed = deepcopy(current) + end_state = [] + + to_delete = [] + if state == 'absent': + for role in current: + if contains_role(roles, role['name']): + to_delete.append(role) + proposed = [x for x in proposed if x['name'] != role['name']] + + if purge_roles: + for role in current: + if not contains_role(roles, role['name']): + to_delete.append(role) + proposed = [x for x in proposed if x['name'] != role['name']] + + if len(to_delete) > 0: + if not module.check_mode: + kc.delete_scope_mapping(cid, to_delete, id_client=scope_client_id, target=target, realm=realm) + removed_roles += len(to_delete) + result['changed'] = True + + available_roles = kc.get_available_roles(cid, id_client=scope_client_id, target=target, realm=realm) + to_update = [] + + if state == 'present': + for role in roles: + # only add roles not already present + if not contains_role(current, role['name']): + proposed.append(role) + # obtain role id if it was not supplied + if 'id' not in role: + candidate = [x for x in available_roles if x['name'] == role['name']] + if len(candidate) < 1: + module.fail_json(msg="Could not find role '%s'" % role['name']) + role['id'] = candidate[0]['id'] + to_update.append(role) + + if len(to_update) > 0: + if not module.check_mode: + kc.create_scope_mapping(cid, to_update, id_client=scope_client_id, target=target, realm=realm) + result['changed'] = True + if len(result['msg']) > 0: + result['msg'] += ' ' + updated_roles += len(to_update) + + # obtain state of mappings after update/delete calls have been completed + end_state = kc.get_scope_mapping(cid, id_client=scope_client_id, target=target, realm=realm) + + # only add things to result dict if they are non-empty; removes output of empty lists + if len(end_state) > 0: + result['end_state'][scope_client or 'realm'] = end_state + if len(current) > 0: + result['existing'][scope_client or 'realm'] = current + if len(proposed) > 0: + result['proposed'][scope_client or 'realm'] = proposed + + if module._diff: + result['diff'] = dict(before=json.dumps(result['existing'], sort_keys=True, indent=4) + '\n', + after=json.dumps(result['proposed' if module.check_mode else 'end_state'], + sort_keys=True, indent=4) + '\n') + + result['msg'] = 'Added/Updated %s scope mapping(s), removed %s scope mapping(s).' % (updated_roles, removed_roles) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() From 07883b3f9902873e54bda81bac6228b4d4ccfe22 Mon Sep 17 00:00:00 2001 From: Eike Frost Date: Fri, 2 Feb 2018 22:53:12 +0100 Subject: [PATCH 07/79] keycloak_realm --- lib/ansible/module_utils/keycloak.py | 74 ++ .../identity/keycloak/keycloak_realm.py | 1060 +++++++++++++++++ 2 files changed, 1134 insertions(+) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_realm.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index ec7e964024385b..692b147b260822 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -36,6 +36,7 @@ from ansible.module_utils._text import to_text from ansible.module_utils.six.moves.urllib.parse import urlencode, quote from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils._text import to_native URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" @@ -67,6 +68,9 @@ URL_CLIENT_SCOPE_MAPPINGS_REALM = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/realm" URL_CLIENT_SCOPE_MAPPINGS_REALM_AVAILABLE = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/realm/available" +URL_REALM = "{url}/admin/realms/{realm}" +URL_REALMS = "{url}/admin/realms" + def keycloak_argument_spec(): """ @@ -400,6 +404,76 @@ def delete_client_template(self, id, realm="master"): self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) + def get_realm_by_name(self, realm): + """ Get a top-level realm representation for a named realm + + :param name: name of the realm + :return: Realm representation as a dict + """ + url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain realm representation for realm %s: %s' % (realm, str(e))) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm representation for realm %s: %s' + % (realm, to_native(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm representation for realm %s: %s' + % (realm, to_native(e))) + + def create_realm(self, realmrep): + """ Create a realm in keycloak + :param realmrep: Realm representation for realm to be created + :param realm: Realm name for realm to be created + :return: HTTPResponse object on success + """ + url = URL_REALMS.format(url=self.baseurl) + + try: + return open_url(url, method='POST', headers=self.restheaders, + data=json.dumps(realmrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create realm: %s' + % to_native(e)) + + def update_realm(self, realmrep, realm): + """ Update an existing realm + + :param realmrep: realm representation with updates + :param realm: realm to be updated + :return: HTTPResponse object on success + """ + url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return open_url(url, method='PUT', headers=self.restheaders, + data=json.dumps(realmrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update realm %s: %s' + % (realm, to_native(e))) + + def delete_realm(self, realm): + """ Delete a realm from Keycloak + + :param realm: realm to be deleted + :return: HTTPResponse object on success + """ + url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return open_url(url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete realm %s: %s' + % (realm, to_native(e))) + def get_scope_mappings(self, id, target='client', realm='master'): """ Get scope mappings for client or client template diff --git a/lib/ansible/modules/identity/keycloak/keycloak_realm.py b/lib/ansible/modules/identity/keycloak/keycloak_realm.py new file mode 100644 index 00000000000000..96c84b66cba360 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_realm.py @@ -0,0 +1,1060 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2018, Eike Frost +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: keycloak_realm + +short_description: Allows administration of Keycloak realms via Keycloak API + +version_added: "2.8" + +description: + - This module allows the administration of Keycloak realms 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(http://www.keycloak.org/docs-api/3.4/rest-api/). + Aliases are provided (if an option has an alias, it is the camelCase'd version used in the + API). + +options: + state: + description: + - State of the realm once this module has run + - On C(present), the realm will be created (or updated if it exists already). + - On C(absent), the realm will be removed if it exists + choices: ['present', 'absent'] + default: 'present' + + name: + description: + - Name of the realm to be acted on. Either this or I(realm) is required. If both are + given, I(name) specifies the current name of the realm, while I(realm) specifies the + new name thereof. + + realm: + description: + - Name of the realm. Either this or I(name) is required. + + access_code_lifespan: + description: + - Client login timeout in seconds. + aliases: + - accessCodeLifespan + + access_code_lifespan_login: + description: + - Login timeout in seconds. + aliases: + - accessCodeLifespanLogin + + access_code_lifespan_user_action: + description: + - User-initiated action lifespan in seconds. + aliases: + - accessCodeLifespanUserAction + + access_token_lifespan: + description: + - Access token lifespan in seconds. + aliases: + - accessTokenLifespan + + access_token_lifespan_for_implicit_flow: + description: + - Access token lifespan for implicit flow in seconds. + aliases: + - accessTokenLifespanForImplicitFlow + + account_theme: + description: + - Theme for user account management pages. Keycloak ships with C(base) and C(keycloak). + aliases: + - accountTheme + + action_token_generated_by_admin_lifespan: + description: + - Default admin-initiated action lifespan in seconds. + aliases: + - actionTokenGeneratedByAdminLifespan + + action_token_generated_by_user_lifespan: + description: + - User-initiated action lifespan in seconds. + aliases: + - actionTokenGeneratedByUserLifespan + + admin_events_details_enabled: + description: + - Include representation for admin events or not. + type: bool + aliases: + - adminEventsDetailsEnabled + + admin_events_enabled: + description: + - Save admin events or not. + type: bool + aliases: + - adminEventsEnabled + + admin_theme: + description: + - Theme for admin console. Keycloak ships with C(base) and C(keycloak). + aliases: + - adminTheme + + attributes: + description: + - A freeform dict used by Keycloak and extensions to save further attributes for a realm. + A non-exhaustive list of options is given below. Some of these are duplications of + other API keys -- as noted via references below. + suboptions: + _browser_header.contentSecurityPolicy: + description: + - See I(browser_security_headers). + _browser_header.strictTransportSecurity: + description: + - See I(browser_security_headers). + _browser_header.xContentTypeOptions: + description: + - See I(browser_security_headers). + _browser_header.xFrameOptions: + description: + - See I(browser_security_headers). + _browser_header.xRobotsTag: + description: + - See I(browser_security_headers). + _browser_header.xXSSProtection: + description: + - See I(browser_security_headers). + actionTokenGeneratedByAdminLifespan: + description: + - See I(action_token_generated_by_admin_lifespan) + actionTokenGeneratedByUserLifespan: + description: + - See I(action_token_generated_by_user_lifespan) + actionTokenGeneratedByUserLifespan.idp-verify-account-via-email: + description: + - Override user-initiated action lifespan for IdP account email verification, in seconds. + actionTokenGeneratedByUserLifespan.reset-credentials: + description: + - Override user-initiated action lifespan for resetting credentials/forget password, in seconds. + actionTokenGeneratedByUserLifespan.verify-email: + description: + - Override user-initiated action lifespan for email verification, in seconds. + actionTokenGeneratedByUserLifespan.execute-actions: + description: + - Override user-initiated action lifespan for execute actions, in seconds. + bruteForceProtected: + description: + - See I(brute_force_protected) + type: bool + displayName: + description: + - See I(display_name) + displayNameHtml: + description: + - See I(display_name_html) + failureFactor: + description: + - See I(failure_factor) + maxDeltaTimeSeconds: + description: + - See I(max_delta_time_seconds) + maxFailureWaitSeconds: + description: + - See I(max_failure_wait_seconds) + minimumQuickLoginWaitSeconds: + description: + - See I(minimum_quick_login_wait_seconds) + quickLoginCheckMilliSeconds: + description: + - See I(quick_login_check_milli_seconds) + waitIncrementSeconds: + description: + - See I(wait_increment_seconds) + + browser_flow: + description: + - Authentication flow binding for browser flow. + aliases: + - browserFlow + + browser_security_headers: + description: + - Browser security headers for this realm. + aliases: + - browserSecurityHeaders + suboptions: + contentSecurityPolicy: + description: + - Content-Security-Policy (CSP) browser header for this realm. + xContentTypeOptions: + description: + - X-Content-Type-Options browser header for this realm. + xFrameOptions: + description: + - X-Frame-Options browser header for this realm. + xRobotsTag: + description: + - X-Robots-Tag browser header for this realm (see U(https://developers.google.com/search/reference/robots_meta_tag)) + - C(none) for no header. + xXSSProtection: + description: + - X-XSS-Protection browser header for this realm. + strictTransportSecurity: + description: + - Strict-Transport-Security (HSTS) browser header for this realm. + + brute_force_protected: + description: + - Is brute force detection enabled for this realm or not. + aliases: + - bruteForceProtected + type: bool + + client_authentication_flow: + description: + - Authentication flow binding for client authentication flow. + aliases: + - clientAuthenticationFlow + + default_groups: + description: + - List of default groups for this realm. Usually with a prepended path, i.e. C(/test) for group C(test). + aliases: + - defaultGroups + + default_locale: + description: + - Default locale for this realm; usually a two-letter country code. + aliases: + - defaultLocale + + default_roles: + description: + - List of default roles for users of this realm + aliases: + - defaultRoles + + direct_grant_flow: + description: + - Authentication flow binding for direct grant flow. + aliases: + - directGrantFlow + + display_name: + description: + - Displayed name of this realm (regular text). + aliases: + - displayName + + display_name_html: + description: + - Displayed name of this realm (HTML) + aliases: + - displayNameHtml + + docker_authentication_flow: + description: + - Authentication flow binding for docker authentication flow. + aliases: + - dockerAuthenticationFlow + + duplicate_emails_allowed: + description: + - Specifies whether multiple users are allowed to have the same email address. + type: bool + aliases: + - duplicateEmailsAllowed + + edit_username_allowed: + description: + - Specifies whether users of this realm are allowed to edit their own username. + type: bool + aliases: + - editUsernameAllowed + + email_theme: + description: + - Theme for mails sent by Keycloak. Keycloak ships with C(base) and C(keycloak). + aliases: + - emailTheme + + enabled: + description: + - Specifies whether this realm is enabled. + type: bool + + enabled_event_types: + description: + - List of event types to be saved. This list may be extensible through SPIs in Keycloak. + Keycloak (3.4) ships the following listed types by default + - C(SEND_RESET_PASSWORD) + - C(REGISTER_NODE_ERROR) + - C(REMOVE_TOTP) + - C(REVOKE_GRANT) + - C(UPDATE_TOTP) + - C(LOGIN_ERROR) + - C(CLIENT_LOGIN) + - C(IDENTITY_PROVIDER_RETRIEVE_TOKEN_ERROR) + - C(RESET_PASSWORD_ERROR) + - C(IMPERSONATE_ERROR) + - C(CODE_TO_TOKEN_ERROR) + - C(CUSTOM_REQUIRED_ACTION) + - C(RESTART_AUTHENTICATION) + - C(CLIENT_INFO) + - C(IMPERSONATE) + - C(UPDATE_PROFILE_ERROR) + - C(VALIDATE_ACCESS_TOKEN) + - C(LOGIN) + - C(UPDATE_PASSWORD_ERROR) + - C(CLIENT_INITIATED_ACCOUNT_LINKING) + - C(IDENTITY_PROVIDER_LOGIN) + - C(TOKEN_EXCHANGE) + - C(LOGOUT) + - C(REGISTER) + - C(CLIENT_INFO_ERROR) + - C(CLIENT_REGISTER) + - C(IDENTITY_PROVIDER_LINK_ACCOUNT) + - C(INTROSPECT_TOKEN_ERROR) + - C(REFRESH_TOKEN) + - C(UPDATE_PASSWORD) + - C(INTROSPECT_TOKEN) + - C(CLIENT_DELETE) + - C(FEDERATED_IDENTITY_LINK_ERROR) + - C(IDENTITY_PROVIDER_FIRST_LOGIN) + - C(CLIENT_DELETE_ERROR) + - C(VERIFY_EMAIL) + - C(CLIENT_LOGIN_ERROR) + - C(RESTART_AUTHENTICATION_ERROR) + - C(EXECUTE_ACTIONS) + - C(REMOVE_FEDERATED_IDENTITY_ERROR) + - C(TOKEN_EXCHANGE_ERROR) + - C(UNREGISTER_NODE) + - C(REGISTER_NODE) + - C(SEND_IDENTITY_PROVIDER_LINK_ERROR) + - C(INVALID_SIGNATURE) + - C(USER_INFO_REQUEST_ERROR) + - C(EXECUTE_ACTION_TOKEN_ERROR) + - C(SEND_VERIFY_EMAIL) + - C(IDENTITY_PROVIDER_RESPONSE) + - C(EXECUTE_ACTIONS_ERROR) + - C(REMOVE_FEDERATED_IDENTITY) + - C(IDENTITY_PROVIDER_RETRIEVE_TOKEN) + - C(IDENTITY_PROVIDER_POST_LOGIN) + - C(IDENTITY_PROVIDER_LINK_ACCOUNT_ERROR) + - C(UNREGISTER_NODE_ERROR) + - C(VALIDATE_ACCESS_TOKEN_ERROR) + - C(UPDATE_EMAIL) + - C(REGISTER_ERROR) + - C(REVOKE_GRANT_ERROR) + - C(EXECUTE_ACTION_TOKEN) + - C(LOGOUT_ERROR) + - C(UPDATE_EMAIL_ERROR) + - C(CLIENT_UPDATE_ERROR) + - C(INVALID_SIGNATURE_ERROR) + - C(UPDATE_PROFILE) + - C(CLIENT_REGISTER_ERROR) + - C(FEDERATED_IDENTITY_LINK) + - C(USER_INFO_REQUEST) + - C(IDENTITY_PROVIDER_RESPONSE_ERROR) + - C(SEND_IDENTITY_PROVIDER_LINK) + - C(SEND_VERIFY_EMAIL_ERROR) + - C(IDENTITY_PROVIDER_LOGIN_ERROR) + - C(RESET_PASSWORD) + - C(CLIENT_INITIATED_ACCOUNT_LINKING_ERROR) + - C(REMOVE_TOTP_ERROR) + - C(VERIFY_EMAIL_ERROR) + - C(SEND_RESET_PASSWORD_ERROR) + - C(CLIENT_UPDATE) + - C(REFRESH_TOKEN_ERROR) + - C(CUSTOM_REQUIRED_ACTION_ERROR) + - C(IDENTITY_PROVIDER_POST_LOGIN_ERROR) + - C(UPDATE_TOTP_ERROR) + - C(CODE_TO_TOKEN) + - C(IDENTITY_PROVIDER_FIRST_LOGIN_ERROR) + aliases: + - enabledEventTypes + + events_enabled: + description: + - Specifies whether to save events or not. + type: bool + aliases: + - eventsEnabled + + events_expiration: + description: + - If I(events_enabled) is True, this specifies the time after which events are expired + automatically, in seconds. + aliases: + - eventsExpiration + + events_listeners: + description: + - List of listeners which receive events for this realm. Keycloak ships with C(jboss-logging) and C(email). + aliases: + - eventsListeners + + failure_factor: + description: + - For brute force detection, how many failures are allowed before a wait is triggered. + aliases: + - failureFactor + + id: + description: + - Internal id of this realm. When a realm is created, this should be the same as I(realm) (and indeed + if you do not specify this parameter it will be set to I(realm) when creating a realm). + + internationalization_enabled: + description: + - Specifies whether internationalization features are enabled for this realm. + type: bool + aliases: + - internationalizationEnabled + + login_theme: + description: + - Theme for the login screen. Keycloak ships with C(base) and C(keycloak). + aliases: + - loginTheme + + login_with_email_allowed: + description: + - Specifies whether users may log in with their email address instead of their username. + type: bool + aliases: + - loginWithEmailAllowed + + max_delta_time_seconds: + description: + - For brute force detection, failure reset time in seconds. + aliases: + - maxDeltaTimeSeconds + + max_failure_wait_seconds: + description: + - For brute force detection, max wait time in seconds. + aliases: + - maxFailureWaitSeconds + + minimum_quick_login_wait_seconds: + description: + - For brute force detection, minimum wait after quick login in seconds. + aliases: + - minimumQuickLoginWaitSeconds + + not_before: + description: + - Unix timestamp specifying a cut-off for tokens and sessions; any tokens issued before + this time are revoked. + aliases: + - notBefore + + offline_session_idle_timeout: + description: + - Time an offline session is allowed to idle before logout, in seconds. + aliases: + - offlineSessionIdleTimeout + + otp_policy_algorithm: + description: + - One time password hash algorithm + choices: + - HmacSHA1 + - HmacSHA256 + - HmacSHA512 + aliases: + - otpPolicyAlgorithm + + otp_policy_digits: + description: + - One time password amount of digits + choices: + - 6 + - 8 + aliases: + - otpPolicyDigits + + otp_policy_initial_counter: + description: + - Initial counter value for tokens when I(otp_policy_type) is set to C(hotp). + aliases: + - otpPolicyInitialCounter + + otp_policy_look_ahead_window: + description: + - One time password lookahead window (in case server and client are out of sync time-wise). + aliases: + - otpPolicyLookAheadWindow + + otp_policy_period: + description: + - One time password token period. + aliases: + - otpPolicyPeriod + + otp_policy_type: + description: + - Type of one time password algorithm + choices: + - totp + - hotp + aliases: + - otpPolicyType + + password_policy: + description: + - String representing the password policy for this realm. An example would be + C(lowerCase(1) and specialChars(1)). Built-in password policy checkers are + - C(lowerCase(n)) + - C(specialChars(n)) + - C(forceExpiredPasswordChange(n)) + - C(hashIterations(n)) + - C(passwordHistory(n)) + - C(upperCase(n)) + - C(length(n)) + - C(digits(n)) + - C(notUsername(undefined)) + - C(hashAlgorithm(pbkdf2-sha256)) + - C(regexPattern(regex)) + - C(passwordBlacklist(filename)) + aliases: + - passwordPolicy + + permanent_lockout: + description: + - For brute force detection, specifies whether a user will be logged out permanently when + the detection triggers. + type: bool + aliases: + - permanentLockout + + quick_login_check_milli_seconds: + description: + - For brute force detection, if failures happen concurrently within this time in + milliseconds, lock out the user. + aliases: + - quickLoginCheckMilliSeconds + + refresh_token_max_reuse: + description: + - Maximum number of times a refresh token can be reused when I(revoke_refresh_token) is set to C(True). + aliases: + - refreshTokenMaxReuse + + registration_allowed: + description: + - Specifies whether user registration is allowed for this realm. + type: bool + aliases: + - registrationAllowed + + registration_email_as_username: + description: + - Specifies whether the email address is used as username when user I(registration_allowed) is C(True). + type: bool + aliases: + - registrationEmailAsUsername + + registration_flow: + description: + - Authentication flow binding for registration flow. + aliases: + - registrationFlow + + remember_me: + description: + - Specifies whether to show a Remember Me checkbox to the user on the login page to allow + reusing a session across browser restarts. + type: bool + aliases: + - rememberMe + + required_credentials: + description: + - A list of required credentials for this realm. By default it just containst C(password). + aliases: + - requiredCredentials + + reset_credentials_flow: + description: + - Authentication flow binding for reset credentials flow. + aliases: + - resetCredentialsFlow + + reset_password_allowed: + description: + - Specifies whether users can request to reset passwords. + type: bool + aliases: + - resetPasswordAllowed + + revoke_refresh_token: + description: + - Specifies whether a refresh token is revoked upon use. + - Also see I(refresh_token_max_reuse) + type: bool + aliases: + - revokeRefreshToken + + smtp_server: + description: + - SMTP server configuration details for sending mails from Keycloak + aliases: + - smtpServer + suboptions: + auth: + description: + - Specifies whether SMTP auth is used. + type: bool + envelopeFrom: + description: + - Envelope from email address. + from: + description: + - From email address. + required: True + fromDisplayName: + description: + - Displayed name for the from email address. + host: + description: + - Hostname of the SMTP server. + required: True + password: + description: + - If I(auth) is set to C(True), password for the SMTP server. + port: + description: + - Port of the SMTP server. + replyTo: + description: + - Reply-to email address. + replyToDisplayName: + description: + - Reply-to email address displayed name. + ssl: + description: + - Specifies whether to use SSL when connecting to the SMTP server. + type: bool + starttls: + description: + - Specifies whether to use STARTTLS when connecting to the SMTP server. + type: bool + user: + description: + - If I(auth) is set to C(True), username for the SMTP server. + + ssl_required: + description: + - Specifies whether HTTPS is enforced and for what addresses. + - C(none) doesn't enforce HTTPS. + - C(all) enforces HTTPS on all requests. + - C(external) enforces HTTPS on all requests that are not localhost or private IP space. + choices: + - none + - all + - external + aliases: + - sslRequired + + sso_session_idle_timeout: + description: + - Time an SSO session is allowed to idle before it expires, in seconds. + aliases: + - ssoSessionIdleTimeout + + sso_session_max_lifespan: + description: + - Maximum lifetime for an SSO session, in seconds. + aliases: + - ssoSessionMaxLifespan + + supported_locales: + description: + - List of supported locales for this realm. Keycloak (3.4) ships with C(ca), C(de), + C(en), C(es), C(fr), C(it), C(ja), C(lt), C(nl), C(no), c(pt-BR), c(ru), c(sv), and + c(zh-CN). + aliases: + - supportedLocales + + verify_email: + description: + - Specifies whether a user needs to verify their email the first time they log in. + type: bool + aliases: + - verifyEmail + + wait_increment_seconds: + description: + - For brute force detection, wait increment in seconds per failure. + aliases: + - waitIncrementSeconds + +notes: + - In the API, a realm representation has further fields which are not covered in this module + since they have their own API endpoints and (hopefully) their own Ansible modules. Sorted by + ansible_module, they are + - M(keycloak_scope) for I(clientScopeMappings) and I(scopeMappings) + - M(keycloak_client) for I(client) + - M(keycloak_clienttemplate) for I(clientTemplates) + - M(keycloak_group) for I(groups) + - No ansible module yet for I(authenticationFlows), I(authenticatorConfig), I(components), + I(users), I(protocolMappers) (but see M(keycloak_client) and M(keycloak_clienttemplate)), + I(identityProviders), I(roles), I(requiredActions), I(userFederationMappers), + I(userFederationProviders), and I(federatedUsers) + +extends_documentation_fragment: + - keycloak + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Create a new Keycloak Realm named my-test-realm + local_action: + module: keycloak_realm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + realm: my-test-realm + +- name: Rename a Keycloak realm from my-test-realm to my-new-test-realm + local_action: + module: keycloak_realm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-test-realm + realm: my-new-test-realm + +- name: Delete a Keycloak realm + local_action: + module: keycloak_realm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + realm: my-new-test-realm + +- name: Create/update a Keycloak realm with some options set + local_action: + module: keycloak_realm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + realm: my-test-realm + access_token_lifespan: 600 + admin_events_enabled: true + admin_theme: keycloak + browser_security_headers: + contentSecurityPolicy: "frame-src 'self'; frame-ancestors 'self'; object-src 'none';" + strictTransportSecurity: "max-age=31536000; includeSubDomains" + xContentTypeOptions: "nosniff" + xFrameOptions: "SAMEORIGIN" + xRobotsTag: "none" + xXSSProtection: "1; mode=block" + password_policy: "lowerCase(1) and specialChars(1)" + smtp_server: + host: test.example.com + from: test@example.com + user: testuser + password: testpassword +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: Realm my-test-realm has been created. + +proposed: + description: realm representation of proposed changes to realm + returned: always + type: dict + sample: { + "realm": "my-test-realm" + } + +existing: + description: realm representation of existing realm (sample is truncated) + returned: always + type: dict + sample: {} + +end_state: + description: realm representation of realm after module execution (sample is truncated) + returned: always + type: dict + sample: { + "accessCodeLifespan": 60, + "accessCodeLifespanLogin": 1800, + "accessCodeLifespanUserAction": 300, + "accessTokenLifespan": 600, + "accessTokenLifespanForImplicitFlow": 900, + "actionTokenGeneratedByAdminLifespan": 43200, + "actionTokenGeneratedByUserLifespan": 300, + "adminEventsDetailsEnabled": false, + "adminEventsEnabled": true, + "adminTheme": "keycloak" + } +''' + +from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + name=dict(), + realm=dict(), + access_code_lifespan=dict(type='int', aliases=['accessCodeLifespan']), + access_code_lifespan_login=dict(type='int', aliases=['accessCodeLifespanLogin']), + access_code_lifespan_user_action=dict(type='int', aliases=['accessCodeLifespanUserAction']), + access_token_lifespan=dict(type='int', aliases=['accessTokenLifespan']), + access_token_lifespan_for_implicit_flow=dict(type='int', aliases=['accessTokenLifespanForImplicitFlow']), + account_theme=dict(aliases=['accountTheme']), + action_token_generated_by_admin_lifespan=dict(type='int', aliases=['actionTokenGeneratedByAdminLifespan']), + action_token_generated_by_user_lifespan=dict(type='int', aliases=['actionTokenGeneratedByUserLifespan']), + admin_events_details_enabled=dict(type='bool', aliases=['adminEventsDetailsEnabled']), + admin_events_enabled=dict(type='bool', aliases=['adminEventsEnabled']), + admin_theme=dict(aliases=['adminTheme']), + attributes=dict(type='dict'), + browser_flow=dict(aliases=['browserFlow']), + browser_security_headers=dict(type='dict', aliases=['browserSecurityHeaders'], options=dict( + contentSecurityPolicy=dict(), + xContentTypeOptions=dict(), + xFrameOptions=dict(), + xRobotsTag=dict(), + xXSSProtection=dict(), + strictTransportSecurity=dict(), + )), + brute_force_protected=dict(type='bool', aliases=['bruteForceProtected']), + client_authentication_flow=dict(aliases=['clientAuthenticationFlow']), + default_groups=dict(type='list', elements='str', aliases=['defaultGroups']), + default_locale=dict(aliases=['defaultLocale']), + default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), + direct_grant_flow=dict(aliases=['directGrantFlow']), + display_name=dict(aliases=['displayName']), + display_name_html=dict(aliases=['displayNameHtml']), + docker_authentication_flow=dict(aliases=['dockerAuthenticationFlow']), + duplicate_emails_allowed=dict(type='bool', aliases=['duplicateEmailsAllowed']), + edit_username_allowed=dict(type='bool', aliases=['editUsernameAllowed']), + email_theme=dict(aliases=['emailTheme']), + enabled=dict(type='bool'), + enabled_event_types=dict(type='list', elements='str', aliases=['enabledEventTypes']), + events_enabled=dict(type='bool', aliases=['eventsEnabled']), + events_expiration=dict(type='int', aliases=['eventsExpiration']), + events_listeners=dict(type='list', elements='str', aliases=['eventsListeners']), + failure_factor=dict(type='int', aliases=['failureFactor']), + id=dict(), + internationalization_enabled=dict(type='bool', aliases=['internationalizationEnabled']), + login_theme=dict(aliases=['loginTheme']), + login_with_email_allowed=dict(type='bool', aliases=['loginWithEmailAllowed']), + max_delta_time_seconds=dict(type='int', aliases=['maxDeltaTimeSeconds']), + max_failure_wait_seconds=dict(type='int', aliases=['maxFailureWaitSeconds']), + minimum_quick_login_wait_seconds=dict(type='int', aliases=['minimumQuickLoginWaitSeconds']), + not_before=dict(type='int', aliases=['notBefore']), + offline_session_idle_timeout=dict(type='int', aliases=['offlineSessionIdleTimeout']), + otp_policy_algorithm=dict(aliases=['otpPolicyAlgorithm'], choices=['HmacSHA1', 'HmacSHA256', 'HmacSHA512']), + otp_policy_digits=dict(type='int', aliases=['otpPolicyDigits'], choices=[6, 8]), + otp_policy_initial_counter=dict(type='int', aliases=['otpPolicyInitialCounter']), + otp_policy_look_ahead_window=dict(type='int', aliases=['otpPolicyLookAheadWindow']), + otp_policy_period=dict(type='int', aliases=['otpPolicyPeriod']), + otp_policy_type=dict(aliases=['otpPolicyType'], choices=['totp', 'hotp']), + password_policy=dict(aliases=['passwordPolicy']), + permanent_lockout=dict(type='bool', aliases=['permanentLockout']), + quick_login_check_milli_seconds=dict(type='int', aliases=['quickLoginCheckMilliSeconds']), + refresh_token_max_reuse=dict(type='int', aliases=['refreshTokenMaxReuse']), + registration_allowed=dict(type='bool', aliases=['registrationAllowed']), + registration_email_as_username=dict(type='bool', aliases=['registrationEmailAsUsername']), + registration_flow=dict(aliases=['registrationFlow']), + remember_me=dict(type='bool', aliases=['rememberMe']), + required_credentials=dict(type='list', elements='str', aliases=['requiredCredentials']), + reset_credentials_flow=dict(aliases=['resetCredentialsFlow']), + reset_password_allowed=dict(type='bool', aliases=['resetPasswordAllowed']), + revoke_refresh_token=dict(type='bool', aliases=['revokeRefreshToken']), + smtp_server=dict(type='dict', aliases=['smtpServer'], options={ + 'auth': dict(type='bool'), + 'envelopeFrom': dict(), + 'from': dict(required=True), + 'fromDisplayName': dict(), + 'host': dict(required=True), + 'password': dict(no_log=True), + 'port': dict(type='int'), + 'replyTo': dict(), + 'replyToDisplayName': dict(), + 'ssl': dict(type='bool'), + 'starttls': dict(type='bool'), + 'user': dict(), + }), + ssl_required=dict(aliases=['sslRequired'], choices=['none', 'all', 'external']), + sso_session_idle_timeout=dict(type='int', aliases=['ssoSessionIdleTimeout']), + sso_session_max_lifespan=dict(type='int', aliases=['ssoSessionMaxLifespan']), + supported_locales=dict(type='list', elements='str', aliases=['supportedLocales']), + verify_email=dict(type='bool', aliases=['verifyEmail']), + wait_increment_seconds=dict(type='int', aliases=['waitIncrementSeconds']), + ) + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['name', 'realm']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + kc = KeycloakAPI(module) + + state = module.params.get('state') + realm = module.params.get('name') if module.params.get('name') is not None else module.params.get('realm') + + # convert module parameters to realm representation parameters (if they belong in there) + realm_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'name'] and + module.params.get(x) is not None] + + before_realm = kc.get_realm_by_name(realm) + before_realm = {} if before_realm is None else before_realm + + # Build a proposed changeset from parameters given to this module + changeset = dict() + + for realm_param in realm_params: + # lists in the Keycloak API are sorted + new_param_value = module.params.get(realm_param) + if isinstance(new_param_value, list): + new_param_value = sorted(new_param_value) + changeset[camel(realm_param)] = new_param_value + + # If the I(realm) is not set, assume I(name) as the default. + if module.params.get('realm') is None: + changeset['realm'] = realm + + # Whether creating or updating a realm, take the before-state and merge the changeset into it + updated_realm = before_realm.copy() + updated_realm.update(changeset) + + result['proposed'] = changeset + result['existing'] = before_realm + + # If the realm does not exist yet, before_realm is still empty + if before_realm == dict(): + if state == 'absent': + # do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'Realm does not exist, doing nothing.' + module.exit_json(**result) + + # create new realm + result['changed'] = True + + # when creating a realm, the id should be the same as the realm name -- otherwise bugs magically appear + if 'id' not in updated_realm: + updated_realm['id'] = updated_realm['realm'] + + if module.check_mode: + if module._diff: + result['diff'] = dict(before='', after=updated_realm) + + module.exit_json(**result) + + kc.create_realm(updated_realm) + after_realm = kc.get_realm_by_name(realm) + + if module._diff: + result['diff'] = dict(before='', after=after_realm) + + result['end_state'] = after_realm + + result['msg'] = 'Realm %s has been created.' % realm + module.exit_json(**result) + else: + if state == 'present': + # update existing realm + result['changed'] = True + if module.check_mode: + # We can only compare the current realm with the proposed updates we have + if module._diff: + result['diff'] = dict(before=before_realm, + after=updated_realm) + result['changed'] = (before_realm != updated_realm) + + module.exit_json(**result) + kc.update_realm(updated_realm, realm=realm) + + if realm != updated_realm['realm']: + after_realm = kc.get_realm_by_name(updated_realm['realm']) + else: + after_realm = kc.get_realm_by_name(realm) + + if before_realm == after_realm: + result['changed'] = False + if module._diff: + result['diff'] = dict(before=before_realm, + after=after_realm) + result['end_state'] = after_realm + + result['msg'] = 'Realm %s has been updated.' % realm + module.exit_json(**result) + else: + # Delete existing realm + result['changed'] = True + if module._diff: + result['diff']['before'] = before_realm + result['diff']['after'] = '' + + if module.check_mode: + module.exit_json(**result) + + kc.delete_realm(realm=realm) + result['proposed'] = dict() + result['end_state'] = dict() + result['msg'] = 'Realm %s has been deleted.' % realm + module.exit_json(**result) + module.exit_json(**result) + + +if __name__ == '__main__': + main() From 4c21e5d5672a672b13a906e44f1d73161d8e6a7d Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 3 Jun 2019 11:41:22 +0200 Subject: [PATCH 08/79] dev: add credentials for user --- lib/ansible/module_utils/keycloak.py | 31 ++++++++++++++----- .../identity/keycloak/keycloak_user.py | 18 ++++++++++- .../identity/keycloak/test_keycloak_user.py | 3 +- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index 692b147b260822..81f12ed2d37bdb 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -28,6 +28,8 @@ from __future__ import absolute_import, division, print_function +from copy import deepcopy + __metaclass__ = type import json @@ -881,6 +883,7 @@ def create_user(self, user_representation, realm="master"): else: user_representation.update({'username': user_name}) user_url = URL_USERS.format(url=self.baseurl, realm=realm) + user_representation = self._put_values_in_list(user_representation, ['credentials']) try: return open_url(user_url, method='POST', headers=self.restheaders, @@ -904,13 +907,8 @@ def update_user(self, uuid, user_representation, realm="master"): user_representation.pop('keycloakUsername') except KeyError: pass - try: - keycloak_attributes = user_representation.pop('keycloakAttributes') - except KeyError: - pass - else: - user_representation.update({'attributes': keycloak_attributes}) - + user_representation = self._put_values_in_list(user_representation, + ['keycloakAttributes', 'credentials']) user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) try: @@ -924,6 +922,25 @@ def update_user(self, uuid, user_representation, realm="master"): user_url=user_url ) + @staticmethod + def _put_values_in_list(representation, key_list): + """Keycloak waits some dictionaries in a list. + + Put the wanted keys into a list of one element. These values are waited + as dictionary by the modules. + + :return: a representation with dictionary in one element list. + """ + new_representation = deepcopy(representation) + for one_key in key_list: + try: + value = new_representation.pop(one_key) + except KeyError: + pass + else: + new_representation.update({one_key: [value]}) + return new_representation + def delete_user(self, id, realm="master"): """ Delete a user from Keycloak diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index e4cc77f681a76f..08e0b10aaeffa3 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -40,11 +40,13 @@ - On C(absent), the user will be removed if it exists choices: [ present, absent ] default: present + type: str realm: description: - The realm to create the user in. default: master + type: str attributes: description: @@ -52,18 +54,21 @@ Keycloak will always return the value in a list of one element. Keys and values are converted into string. required: false + type: dict user_id: description: - user_id of client to be worked on. This is usually an UUID. This and I(client_username) are mutually exclusive. aliases: [ userId ] + type: str keycloak_username: description: - username of user to be worked on. This and I(user_id) are mutually exclusive. - keycloak lower the username aliases: [ keycloakUsername ] + type: str email_verified: description: @@ -85,6 +90,7 @@ - when using the api, there is no check about the validity of the email in keycloak - but with manual action, the format is checked required: false + type: str required_actions: description: @@ -92,16 +98,24 @@ - each element must be in the choices choices: [ UPDATE_PROFILE, VERIFY_EMAIL, UPDATE_PASSWORD, CONFIGURE_TOTP ] aliases: [ requiredActions ] + type: list first_name: description: - the user first name aliases: [ firstName ] + type: str last_name: description: - the user last name aliases: [ lastName ] + type: str + + credentials: + description: + - a dictionary setting the user password. + type: dict extends_documentation_fragment: - keycloak @@ -144,6 +158,7 @@ last_name: test required_actions: [ UPDATE_PROFILE, CONFIGURE_TOTP ] attributes: {'one key': 'one value', 'another key': 42} + credentials: {'type': 'password', 'user_secret'} ''' RETURN = ''' @@ -224,7 +239,8 @@ def run_module(): first_name=dict(type='str', aliases=['firstName']), last_name=dict(type='str', aliases=['lastName']), required_actions=dict(type='list', aliases=['requiredActions'], - choices=AUTHORIZED_REQUIRED_ACTIONS) + choices=AUTHORIZED_REQUIRED_ACTIONS), + credentials=dict(type='dict'), ) argument_spec.update(meta_args) diff --git a/test/units/modules/identity/keycloak/test_keycloak_user.py b/test/units/modules/identity/keycloak/test_keycloak_user.py index 34744c56befa23..6aad6fe130b1bf 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_user.py +++ b/test/units/modules/identity/keycloak/test_keycloak_user.py @@ -329,7 +329,8 @@ def test_state_present_should_update_existing_user(monkeypatch, dynamic_url_for_ 'auth_password': 'admin_password', 'auth_realm': 'master', 'state': 'present', - 'email': 'user1@domain.net' + 'email': 'user1@domain.net', + 'credentials': {'type': 'password', 'value': 'user1_secret'} } arguments.update(user_to_update) set_module_args(arguments) From ec1d103456dd1f47640dbfcd783f20bdab0e0e7d Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 6 Jun 2019 15:36:46 +0200 Subject: [PATCH 09/79] test: add role already in client This test should not raise an error as before --- .../identity/keycloak/test_keycloak_role.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/test/units/modules/identity/keycloak/test_keycloak_role.py b/test/units/modules/identity/keycloak/test_keycloak_role.py index e4cc038733505e..59a85c231691fa 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_role.py +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -182,6 +182,48 @@ def test_state_absent_should_not_create_absent_role( assert ansible_exit_json['msg'] == (error_message + ', doing nothing.') +@pytest.fixture() +def mock_already_here_role_in_client_url(mocker): + already_here_role_url = CONNECTION_DICT.copy() + already_here_role_url.update({ + 'http://keycloak.url/auth/admin/realms/master/clients?clientId=client-with-role': create_wrapper(json.dumps(MASTER_CLIENTS)), + 'http://keycloak.url/auth/admin/realms/master/clients/11111111-1111-1111-1111-111111111111/roles/already_here': create_wrapper((json.dumps( + {'name': 'already_here', + 'description': '', + 'composite': False, 'attributes': {}, 'clientRole': True, + 'containerId': '11111111-1111-1111-1111-111111111111', + 'id': 'gggggggg-1111-1111-1111-111111111111'} + ))) + }) + return mocker.patch( + 'ansible.module_utils.keycloak.open_url', + side_effect=build_mocked_request(count(), already_here_role_url), + autospec=True + ) + + +def test_update_role_in_client_with_same_values_should_not_do_something(monkeypatch, mock_already_here_role_in_client_url): + monkeypatch.setattr(keycloak_role.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_role.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'name': 'already_here', + 'client_id': 'client-with-role' + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_role.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == ( + 'Role already_here in client client-with-role of realm master is not modified, doing nothing.') + + @pytest.fixture def mock_delete_role_urls(mocker): delete_role_urls = CONNECTION_DICT.copy() From 89a0ef6892c0ad05f782a94034a714d2eaacd735 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 6 Jun 2019 15:37:11 +0200 Subject: [PATCH 10/79] dev: manage role in client edge case --- .../identity/keycloak/keycloak_role.py | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_role.py b/lib/ansible/modules/identity/keycloak/keycloak_role.py index aa99b5102f22a1..03852d999dabb4 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_role.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -4,6 +4,11 @@ # Copyright: (c) 2018, Nicolas Duclert # 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 + +from copy import deepcopy + +from ansible.module_utils.common.dict_transformations import recursive_diff + __metaclass__ = type ANSIBLE_METADATA = { @@ -224,7 +229,17 @@ def run_module(): create_role(kc, result, realm, given_role_id, client_id) else: if state == 'present': - updating_role(kc, result, realm, given_role_id, client_uuid) + if client_uuid: + # When the role is in a client, the API return an 400 status if there is nothing to do. + role_representation = deepcopy(result['proposed']) + role_representation.pop('clientId') + _, role_diff = recursive_diff(before_role, role_representation) + if role_diff: + updating_role(kc, result, realm, given_role_id, client_uuid) + else: + do_nothing_and_exit(kc, result, realm, given_role_id, client_id, client_uuid) + else: + updating_role(kc, result, realm, given_role_id, client_uuid) else: deleting_role(kc, result, realm, given_role_id, client_uuid) @@ -301,19 +316,24 @@ def do_nothing_and_exit(kc, result, realm, given_role_id, client_id, client_uuid module = kc.module message_role_id = list(given_role_id.values())[0] if module._diff: - result['diff'] = dict(before='', after='') - if client_id: - if not client_uuid: - result['msg'] = ( - 'Client %s does not exist in %s, cannot found role %s in it, doing nothing.' - % (to_text(client_id), realm, to_text(message_role_id))) - else: - result['msg'] = ( - 'Role %s does not exist in client %s of realm %s, doing nothing.' - % (to_text(message_role_id), to_text(client_id), to_text(realm))) + result['diff'] = dict(before=result['existing'], after=result['existing']) + if result['existing']: + result['msg'] = ( + 'Role %s in client %s of realm %s is not modified, doing nothing.' % ( + to_text(message_role_id), to_text(client_id), to_text(realm))) else: - result['msg'] = ('Role %s does not exist in realm %s, doing nothing.' - % (to_text(message_role_id), to_text(realm))) + if client_id: + if not client_uuid: + result['msg'] = ( + 'Client %s does not exist in %s, cannot found role %s in it, doing nothing.' + % (to_text(client_id), realm, to_text(message_role_id))) + else: + result['msg'] = ( + 'Role %s does not exist in client %s of realm %s, doing nothing.' + % (to_text(message_role_id), to_text(client_id), to_text(realm))) + else: + result['msg'] = ('Role %s does not exist in realm %s, doing nothing.' + % (to_text(message_role_id), to_text(realm))) module.exit_json(**result) From b8181825508cc989078689410e65cd1dfa713476 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 7 Jun 2019 08:59:11 +0200 Subject: [PATCH 11/79] doc: add a value to user_secret in example --- lib/ansible/modules/identity/keycloak/keycloak_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index 08e0b10aaeffa3..053f6a78901efc 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -158,7 +158,7 @@ last_name: test required_actions: [ UPDATE_PROFILE, CONFIGURE_TOTP ] attributes: {'one key': 'one value', 'another key': 42} - credentials: {'type': 'password', 'user_secret'} + credentials: {'type': 'password', 'user_secret': 'userTest1secret'} ''' RETURN = ''' From ab82d3c5602ae4b0c18779f1a08e2c9d3e593d8f Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 7 Jun 2019 09:49:05 +0200 Subject: [PATCH 12/79] extract the keycloak authentification into a dedicated class --- .github/BOTMETA.yml | 2 +- lib/ansible/module_utils/identity/__init__.py | 0 .../identity/keycloak/__init__.py | 0 .../{ => identity/keycloak}/keycloak.py | 113 ++++++------ .../identity/keycloak/keycloak_client.py | 14 +- .../keycloak/keycloak_clienttemplate.py | 14 +- .../identity/keycloak/keycloak_group.py | 14 +- .../keycloak/test_keycloak_connect.py | 167 ++++++++++++++++++ 8 files changed, 266 insertions(+), 58 deletions(-) create mode 100644 lib/ansible/module_utils/identity/__init__.py create mode 100644 lib/ansible/module_utils/identity/keycloak/__init__.py rename lib/ansible/module_utils/{ => identity/keycloak}/keycloak.py (97%) create mode 100644 test/units/module_utils/identity/keycloak/test_keycloak_connect.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index ed08e1126fe6e7..66d0e2a2896fe3 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -694,7 +694,7 @@ files: labels: - clustering - k8s - $module_utils/keycloak.py: + $module_utils/identity/keycloak/: maintainers: eikef $module_utils/kubevirt.py: *kubevirt $module_utils/manageiq.py: diff --git a/lib/ansible/module_utils/identity/__init__.py b/lib/ansible/module_utils/identity/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/lib/ansible/module_utils/identity/keycloak/__init__.py b/lib/ansible/module_utils/identity/keycloak/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py similarity index 97% rename from lib/ansible/module_utils/keycloak.py rename to lib/ansible/module_utils/identity/keycloak/keycloak.py index 81f12ed2d37bdb..2318e64bc0d453 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -132,49 +132,60 @@ def check_role_representation(rep): return True, '' +class KeycloakError(Exception): + pass + + +class KeycloakAuthorizationHeader(object): + def __init__(self, base_url, validate_certs, auth_realm, client_id, + auth_username, auth_password, client_secret): + self.validate_certs = validate_certs + self.auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) + temp_payload = { + 'grant_type': 'password', + 'client_id': client_id, + 'client_secret': client_secret, + 'username': auth_username, + 'password': auth_password, + } + # Remove empty items, for instance missing client_secret + self.payload = dict( + (k, v) for k, v in temp_payload.items() if v is not None) + self.header = {} + self.refresh_token() + + def refresh_token(self): + try: + r = json.load(open_url(self.auth_url, method='POST', + validate_certs=self.validate_certs, + data=urlencode(self.payload))) + except ValueError as e: + raise KeycloakError( + 'API returned invalid JSON when trying to obtain access token from %s: %s' + % (self.auth_url, str(e))) + except Exception as e: + raise KeycloakError('Could not obtain access token from %s: %s' + % (self.auth_url, str(e))) + + try: + self.header = { + 'Authorization': 'Bearer ' + r['access_token'], + 'Content-Type': 'application/json' + } + except KeyError: + raise KeycloakError( + 'Could not obtain access token from %s' % self.auth_url) + + class KeycloakAPI(object): """ Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which is obtained through OpenID connect """ - def __init__(self, module): + def __init__(self, module, connection_header): self.module = module - self.token = None - self._connect() - - def _connect(self): - """ Obtains an access_token and saves it for use in API accesses - """ self.baseurl = self.module.params.get('auth_keycloak_url') self.validate_certs = self.module.params.get('validate_certs') - - auth_url = URL_TOKEN.format(url=self.baseurl, realm=self.module.params.get('auth_realm')) - - payload = {'grant_type': 'password', - 'client_id': self.module.params.get('auth_client_id'), - 'client_secret': self.module.params.get('auth_client_secret'), - 'username': self.module.params.get('auth_username'), - 'password': self.module.params.get('auth_password')} - - # Remove empty items, for instance missing client_secret - payload = dict((k, v) for k, v in payload.items() if v is not None) - try: - r = json.load(open_url(auth_url, method='POST', - validate_certs=self.validate_certs, data=urlencode(payload))) - except ValueError as e: - self.module.fail_json(msg='API returned invalid JSON when trying to obtain access token from %s: %s' - % (auth_url, str(e))) - except Exception as e: - self.module.fail_json(msg='Could not obtain access token from %s: %s' - % (auth_url, str(e)), - payload=payload) - - if 'access_token' in r: - self.token = r['access_token'] - self.restheaders = {'Authorization': 'Bearer ' + self.token, - 'Content-Type': 'application/json'} - - else: - self.module.fail_json(msg='Could not obtain access token from %s' % auth_url) + self.restheaders = connection_header def get_clients(self, realm='master', filter=None): """ Obtains client representations for clients in a realm @@ -188,7 +199,7 @@ def get_clients(self, realm='master', filter=None): clientlist_url += '?clientId=%s' % filter try: - return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders, + return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' @@ -219,7 +230,7 @@ def get_client_by_id(self, id, realm='master'): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return json.load(open_url(client_url, method='GET', headers=self.restheaders, + return json.load(open_url(client_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: @@ -258,7 +269,7 @@ def update_client(self, id, clientrep, realm="master"): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='PUT', headers=self.restheaders, + return open_url(client_url, method='PUT', headers=self.restheaders.header, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update client %s in realm %s: %s' @@ -273,7 +284,7 @@ def create_client(self, clientrep, realm="master"): client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) try: - return open_url(client_url, method='POST', headers=self.restheaders, + return open_url(client_url, method='POST', headers=self.restheaders.header, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create client %s in realm %s: %s' @@ -289,7 +300,7 @@ def delete_client(self, id, realm="master"): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='DELETE', headers=self.restheaders, + return open_url(client_url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete client %s in realm %s: %s' @@ -304,7 +315,7 @@ def get_client_templates(self, realm='master'): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' @@ -323,7 +334,7 @@ def get_client_template_by_id(self, id, realm='master'): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' @@ -369,7 +380,7 @@ def update_client_template(self, id, clienttrep, realm="master"): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='PUT', headers=self.restheaders, + return open_url(url, method='PUT', headers=self.restheaders.header, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update client template %s in realm %s: %s' @@ -384,7 +395,7 @@ def create_client_template(self, clienttrep, realm="master"): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='POST', headers=self.restheaders, + return open_url(url, method='POST', headers=self.restheaders.header, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create client template %s in realm %s: %s' @@ -400,7 +411,7 @@ def delete_client_template(self, id, realm="master"): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='DELETE', headers=self.restheaders, + return open_url(url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' @@ -607,7 +618,7 @@ def get_groups(self, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" @@ -624,7 +635,7 @@ def get_group_by_groupid(self, gid, realm="master"): """ groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: @@ -729,7 +740,7 @@ def create_group(self, grouprep, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return open_url(groups_url, method='POST', headers=self.restheaders, + return open_url(groups_url, method='POST', headers=self.restheaders.header, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg="Could not create group %s in realm %s: %s" @@ -744,7 +755,7 @@ def update_group(self, grouprep, realm="master"): group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) try: - return open_url(group_url, method='PUT', headers=self.restheaders, + return open_url(group_url, method='PUT', headers=self.restheaders.header, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update group %s in realm %s: %s' @@ -781,7 +792,7 @@ def delete_group(self, name=None, groupid=None, realm="master"): # should have a good groupid by here. group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) try: - return open_url(group_url, method='DELETE', headers=self.restheaders, + return open_url(group_url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py index 548fb9b55cc73c..e0d5f9f6d64d32 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client.py @@ -628,7 +628,8 @@ } ''' -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, KeycloakAuthorizationHeader from ansible.module_utils.basic import AnsibleModule @@ -714,7 +715,16 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') cid = module.params.get('id') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py index 7bd0b927cdfd72..8cc8f85d43f55c 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py @@ -246,7 +246,8 @@ } ''' -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, KeycloakAuthorizationHeader from ansible.module_utils.basic import AnsibleModule @@ -289,7 +290,16 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') state = module.params.get('state') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group.py b/lib/ansible/modules/identity/keycloak/keycloak_group.py index 0d6ba686b5d885..bbc87bdb7219f2 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group.py @@ -207,7 +207,8 @@ view: true ''' -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, KeycloakAuthorizationHeader from ansible.module_utils.basic import AnsibleModule @@ -235,7 +236,16 @@ def main(): result = dict(changed=False, msg='', diff={}, group='') # Obtain access token, initialize API - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') state = module.params.get('state') diff --git a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py new file mode 100644 index 00000000000000..526a49b4a60e62 --- /dev/null +++ b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py @@ -0,0 +1,167 @@ +from __future__ import (absolute_import, division, print_function) + +import pytest +from itertools import count + +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAuthorizationHeader, + KeycloakError, +) +from ansible.module_utils.six import StringIO +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +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 + + +@pytest.fixture() +def mock_good_connection(mocker): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'),} + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +def test_connect_to_keycloak(mock_good_connection): + keycloak_header = KeycloakAuthorizationHeader( + base_url='http://keycloak.url/auth', + validate_certs=True, + auth_realm='master', + client_id='admin-cli', + auth_username='admin', + auth_password='admin', + client_secret=None + ) + assert keycloak_header.header == { + 'Authorization': 'Bearer alongtoken', + 'Content-Type': 'application/json' + } + + +@pytest.fixture() +def mock_bad_json_returned(mocker): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token":'),} + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +def test_bad_json_returned(mock_bad_json_returned): + with pytest.raises(KeycloakError) as raised_error: + KeycloakAuthorizationHeader( + base_url='http://keycloak.url/auth', + validate_certs=True, + auth_realm='master', + client_id='admin-cli', + auth_username='admin', + auth_password='admin', + client_secret=None + ) + assert str(raised_error.value) == ( + 'API returned invalid JSON when trying to obtain access token from ' + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token: ' + 'Expecting value: line 1 column 17 (char 16)' + ) + + +def raise_401(url): + def _raise_401(): + raise HTTPError(url=url, code=401, msg='Unauthorized', hdrs='', fp=StringIO('')) + return _raise_401 + + +@pytest.fixture() +def mock_401_returned(mocker): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': raise_401( + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token'), + } + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +def test_error_returned(mock_401_returned): + with pytest.raises(KeycloakError) as raised_error: + KeycloakAuthorizationHeader( + base_url='http://keycloak.url/auth', + validate_certs=True, + auth_realm='master', + client_id='admin-cli', + auth_username='notadminuser', + auth_password='notadminpassword', + client_secret=None + ) + assert str(raised_error.value) == ( + 'Could not obtain access token from http://keycloak.url' + '/auth/realms/master/protocol/openid-connect/token: ' + 'HTTP Error 401: Unauthorized' + ) + + +@pytest.fixture() +def mock_json_without_token_returned(mocker): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"not_token": "It is not a token"}'),} + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +def test_json_without_token_returned(mock_json_without_token_returned): + with pytest.raises(KeycloakError) as raised_error: + KeycloakAuthorizationHeader( + base_url='http://keycloak.url/auth', + validate_certs=True, + auth_realm='master', + client_id='admin-cli', + auth_username='admin', + auth_password='admin', + client_secret=None + ) + assert str(raised_error.value) == ( + 'Could not obtain access token from http://keycloak.url' + '/auth/realms/master/protocol/openid-connect/token' + ) From e6d40454411d40b3ef2a5e35e2c6f9e0e32cb0fa Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 10 Jun 2019 12:40:55 +0200 Subject: [PATCH 13/79] pep8 --- .../identity/keycloak/test_keycloak_connect.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py index 526a49b4a60e62..dae602110a107c 100644 --- a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py +++ b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py @@ -49,7 +49,7 @@ def _create_wrapper(): @pytest.fixture() def mock_good_connection(mocker): token_response = { - 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'),} + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), } return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), token_response), @@ -66,7 +66,7 @@ def test_connect_to_keycloak(mock_good_connection): auth_username='admin', auth_password='admin', client_secret=None - ) + ) assert keycloak_header.header == { 'Authorization': 'Bearer alongtoken', 'Content-Type': 'application/json' @@ -76,7 +76,7 @@ def test_connect_to_keycloak(mock_good_connection): @pytest.fixture() def mock_bad_json_returned(mocker): token_response = { - 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token":'),} + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token":'), } return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), token_response), @@ -142,7 +142,7 @@ def test_error_returned(mock_401_returned): @pytest.fixture() def mock_json_without_token_returned(mocker): token_response = { - 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"not_token": "It is not a token"}'),} + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"not_token": "It is not a token"}'), } return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), token_response), From 62de0c18d588bcc022bb50add120cb01fe3df982 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 10 Jun 2019 12:49:16 +0200 Subject: [PATCH 14/79] test: change waited error --- .../identity/keycloak/test_keycloak_connect.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py index dae602110a107c..6fb15fc4669625 100644 --- a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py +++ b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py @@ -95,11 +95,12 @@ def test_bad_json_returned(mock_bad_json_returned): auth_password='admin', client_secret=None ) - assert str(raised_error.value) == ( + # cannot check all the message, different errors message for the value + # error in python 2.6, 2.7 and 3.*. + assert ( 'API returned invalid JSON when trying to obtain access token from ' 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token: ' - 'Expecting value: line 1 column 17 (char 16)' - ) + ) in str(raised_error.value) def raise_401(url): From 384049187a80573b07887db45c887e13bd1d382f Mon Sep 17 00:00:00 2001 From: ndclt Date: Wed, 12 Jun 2019 08:31:56 +0200 Subject: [PATCH 15/79] Update .github/BOTMETA.yml Add community support for keycloak module_utils Co-Authored-By: Sviatoslav Sydorenko --- .github/BOTMETA.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 66d0e2a2896fe3..bb18d41d5aac3a 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -695,7 +695,8 @@ files: - clustering - k8s $module_utils/identity/keycloak/: - maintainers: eikef + maintainers: eikef + support: community $module_utils/kubevirt.py: *kubevirt $module_utils/manageiq.py: maintainers: $team_manageiq From 5439e36298c778b036fbed46d551b7aa971c2400 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 10 Jun 2019 11:10:21 +0200 Subject: [PATCH 16/79] dev: add federation crud The documentation is not ready yet refactor: change import for ldap federation module --- .../keycloak/keycloak_ldap_federation.py | 467 +++++++++++++++ .../keycloak/test_keycloak_ldap_federation.py | 532 ++++++++++++++++++ 2 files changed, 999 insertions(+) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py new file mode 100644 index 00000000000000..d275da9e3009e1 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -0,0 +1,467 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +import json +from copy import deepcopy +from ansible.module_utils._text import to_text +from ansible.module_utils.identity.keycloak.keycloak import ( + camel, + keycloak_argument_spec, + KeycloakAuthorizationHeader, +) +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.parse import quote, urlencode +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' +USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' +COMPONENTS_URL = '{url}/admin/realms/{realm}/components/' +TEST_LDAP_CONNECTION = '{url}/admin/realms/{realm}/testLDAPConnection' + + +SEARCH_SCOPE = {'one level': 1, 'subtree': 2} + + +class LdapFederation(object): + def __init__(self, module, connection_header): + self.module = module + self.restheaders = connection_header + self.federation = self.get_federation() + try: + self.uuid = self.federation['id'] + except KeyError: + self.uuid = '' + + def _get_federation_url(self): + try: + return USER_FEDERATION_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=self.uuid, + ) + except AttributeError: + if self.module.params.get('federation_id'): + return USER_FEDERATION_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + federation_id=quote(self.module.params.get('federation_id')), + ) + return USER_FEDERATION_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=quote(self.module.params.get('federation_uuid')), + ) + + def get_federation(self): + get_url = self._get_federation_url() + realm = self.module.params.get('realm') + try: + json_federation = json.load( + open_url( + get_url, + method='GET', + headers=self.restheaders.header, + validate_certs=self.module.params.get('validate_certs'), + ) + ) + except HTTPError as e: + if e.code == 404: + return {} + else: + self.module.fail_json( + msg='Could not obtain user federation %s for realm %s: %s' + % (to_text(self.given_id), to_text(realm), to_text(e)) + ) + except ValueError as e: + self.module.fail_json( + msg=( + 'API returned incorrect JSON when trying to obtain user ' + 'federation %s for realm %s: %s' + ) + % (to_text(self.given_id), to_text(realm), to_text(e)) + ) + except Exception as e: + self.module.fail_json( + msg='Could not obtain user federation %s for realm %s: %s' + % (to_text(self.given_id), to_text(realm), to_text(e)) + ) + else: + if json_federation: + try: + return json_federation[0] + except KeyError: + return json_federation + return {} + + @property + def given_id(self): + if self.module.params.get('federation_id'): + return self.module.params.get('federation_id') + return self.module.params.get('federation_uuid') + + def delete(self): + federation_url = self._get_federation_url() + try: + open_url( + federation_url, + method='DELETE', + headers=self.restheaders.header, + validate_certs=self.module.params.get('validate_certs'), + ) + except Exception as e: + self.module.fail_json( + msg='Could not delete federation %s in realm %s: %s' + % (self.given_id, self.module.params.get('realm'), str(e)) + ) + + def update(self): + federation_payload = self.create_payload() + put_url = USER_FEDERATION_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=self.uuid, + ) + if self.module.params.get('test_connection'): + self._test_connection() + if self.module.params.get('test_authentication'): + self._test_connection() + self._test_authentication() + try: + open_url( + put_url, + method='PUT', + headers=self.restheaders.header, + validate_certs=self.module.params.get('validate_certs'), + data=json.dumps(federation_payload), + ) + except Exception as e: + self.module.fail_json( + msg='Could not create federation %s in realm %s: %s' + % (self.given_id, self.module.params.get('realm'), str(e)) + ) + return self._clean_payload(federation_payload) + + def create(self): + federation_payload = self.create_payload() + self.check_mandatory_arguments(federation_payload) + post_url = COMPONENTS_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + ) + if self.module.params.get('test_connection'): + self._test_connection() + if self.module.params.get('test_authentication'): + self._test_connection() + self._test_authentication() + try: + open_url( + post_url, + method='POST', + headers=self.restheaders.header, + validate_certs=self.module.params.get('validate_certs'), + data=json.dumps(federation_payload), + ) + except Exception as e: + self.module.fail_json( + msg='Could not create federation %s in realm %s: %s' + % (self.given_id, self.module.params.get('realm'), str(e)) + ) + return self._clean_payload(federation_payload) + + def _test_connection(self): + if not self._call_test_url({'action': 'testConnection'}): + self.module.fail_json( + msg='The url connection %s cannot be reached.' + % (self.module.params.get('connection_url')) + ) + + def _test_authentication(self): + if not self._call_test_url({'action': 'testAuthentication'}): + self.module.fail_json( + msg='The user %s cannot logged in the ldap at %s, ' + 'you should check your credentials.' + % ( + self.module.params.get('bind_dn'), + self.module.params.get('connection_url'), + ) + ) + + def _call_test_url(self, extra_arguments): + payload = { + 'bindCredential': self.module.params.get('bind_credential', ''), + 'bindDn': self.module.params.get('bind_dn', ''), + 'connectionUrl': self.module.params.get('connection_url'), + 'connectionTimeout': '', + 'realm': self.module.params.get('realm'), + 'useTruststoreSpi': self.module.params.get('useTruststoreSpi', 'ldapsOnly'), + } + payload.update(extra_arguments) + test_url = TEST_LDAP_CONNECTION.format( + url=self.module.params.get('auth_keycloak_url'), + realm=self.module.params.get('realm'), + ) + headers = deepcopy(self.restheaders.header) + headers.update( + {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} + ) + try: + open_url( + test_url, + method='POST', + headers=headers, + validate_certs=self.module.params.get('validate_certs'), + data=urlencode(payload), + ) + except HTTPError as http_error: + if http_error.code == 400: + return False + self.module.fail_json( + msg='Could not test connection %s in realm %s: %s' + % (self.given_id, self.module.params.get('realm'), str(http_error)) + ) + except Exception as e: + self.module.fail_json( + msg='Could not test connection %s in realm %s: %s' + % (self.given_id, self.module.params.get('realm'), str(e)) + ) + return True + + def create_payload(self): + translation = {'federation_id': 'name', 'federation_uuid': 'id'} + config = {} + payload = { + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + } + not_federation_argument = list(keycloak_argument_spec().keys()) + [ + 'state', + 'realm', + ] + for key, value in self.module.params.items(): + if value is not None and key not in not_federation_argument: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + else: + if key == 'search_scope': + config.update({camel(key): [SEARCH_SCOPE[value]]}) + else: + config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) + try: + config['priority'] + except KeyError: + config.update({'priority': [0]}) + # yet I don't need connection pooling to True but this key is mandatory. + config.update({'connectionPooling': [False]}) + payload.update({'config': config}) + return payload + + def get_result(self): + return self._clean_payload(self.create_payload()) + + @staticmethod + def _clean_payload(payload): + clean_payload = deepcopy(payload) + old_config = clean_payload.pop('config') + new_config = {} + for key, value in old_config.items(): + if key != 'bindCredential': + new_config.update({key: value[0]}) + else: + new_config.update({key: 'no_log'}) + clean_payload.update({'config': new_config}) + return clean_payload + + def check_mandatory_arguments(self, creation_payload): + mandatory_elements = [ + 'priority', + 'vendor', + 'username_ldap_attribute', + 'rdn_ldap_attribute', + 'uuid_ldap_attribute', + 'user_object_classes', + 'connection_url', + 'users_dn', + 'bind_dn', + 'bind_credential', + ] + missing_element = [] + for one_mandatory in mandatory_elements: + search_key = camel(one_mandatory).replace('Ldap', 'LDAP') + if search_key not in creation_payload['config']: + missing_element.append(one_mandatory) + if not missing_element: + return None + if len(missing_element) > 1: + missing_element.sort() + elements_for_message = ', '.join(missing_element[:-1]) + elements_for_message += ' and {} are missing'.format(missing_element[-1]) + else: + elements_for_message = missing_element[0] + 'is missing' + elements_for_message += ' for the federation creation.' + self.module.fail_json(msg=elements_for_message) + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + federation_id=dict(type='str', aliases=['federerationId']), + federation_uuid=dict(type='str', aliases=['federationUuid']), + enable=dict(type='bool'), + pagination=dict(type='bool'), + vendor=dict(type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory']), + edit_mode=dict( + type='str', + choices=['READ_ONLY', 'UNSYNCED', 'WRITABLE'], + aliases=['editMode'], + ), + import_enable=dict(type='bool', aliases=['importEnable']), + synchronize_registrations=dict( + type='bool', + aliases=[ + 'sync_registrations', + 'synchronizeRegistrations', + 'syncRegistrations', + ], + ), + username_ldap_attribute=dict( + type='str', + aliases=[ + 'usernameLDAPAttribute', + 'username_LDAP_attribute', + 'usernameLdapAttribute', + ], + ), + rdn_ldap_attribute=dict( + type='str', + aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], + ), + user_object_classes=dict(type='str', aliases=['userObjectClasses']), + connection_url=dict(type='str', aliases=['connectionUrl']), + users_dn=dict(type='str', aliases=['usersDn']), + bind_dn=dict(type='str', aliases=['bindDn']), + bind_credential=dict(type='str', aliases=['bindCredential'], no_log=True), + custom_user_ldap_filter=dict( + type='str', + aliases=[ + 'customUserSearchFilter', + 'custom_user_search_filter', + 'customUserLdapFilter', + 'customUserLDAPFilter', + 'custom_user_LDAP_filter', + ], + ), + uuid_ldap_attribute=dict( + type='str', + aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', 'uuid_LDAP_attribute'], + ), + search_scope=dict( + type='str', choices=['one level', 'subtree'], aliases=['searchScope'] + ), + use_truststore_spi=dict( + type='str', + choices=['ldapsOnly', 'always', 'never'], + aliases=['useTruststoreSpi'], + ), + test_connection=dict(type='bool', aliases=['testConnection']), + test_authentication=dict(type='bool', aliases=['testAuthentication']), + ) + # option not taken into account: + # cache_policy=dict(type=str, choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN'], aliases=['cachePolicy']) + # authentication_type: (authType in json) value: ["simple", "none"], default simple + + argument_spec.update(meta_args) + + # The id of the role is unique in keycloak and if it is given the + # client_id is not used. In order to avoid confusion, I set a mutual + # exclusion. + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['federation_id', 'federation_uuid']], + mutually_exclusive=[ + ['federation_id', 'federation_uuid'], + ['test_connection', 'test_authentication'], + ], + ) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + ldap_federation = LdapFederation(module, connection_header) + waited_state = module.params.get('state') + result = {} + if waited_state == 'absent': + if not ldap_federation.federation: + result['msg'] = to_text( + 'Federation {given_id} does not exist, doing nothing.'.format( + given_id=ldap_federation.given_id + ) + ) + result['changed'] = False + else: + if not module.check_mode: + ldap_federation.delete() + result['msg'] = to_text( + 'Federation {given_id} deleted.'.format( + given_id=ldap_federation.given_id + ) + ) + result['changed'] = True + result['ldap_federation'] = {} + else: + if not ldap_federation.federation: + if not module.check_mode: + payload = ldap_federation.create() + else: + payload = ldap_federation.create_payload() + + result['msg'] = to_text( + 'Federation {given_id} created.'.format( + given_id=ldap_federation.given_id + ) + ) + result['changed'] = True + result['ldap_federation'] = payload + else: + if not module.check_mode: + payload = ldap_federation.update() + else: + payload = ldap_federation.create_payload() + result['msg'] = to_text( + 'Federation {given_id} updated.'.format( + given_id=ldap_federation.given_id + ) + ) + result['changed'] = True + result['ldap_federation'] = payload + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py new file mode 100644 index 00000000000000..e541b0aded572f --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -0,0 +1,532 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +import json +from itertools import count, filterfalse + +import pytest + +from ansible.module_utils.six import StringIO +from ansible.modules.identity.keycloak import keycloak_ldap_federation +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) +from ansible.module_utils._text import to_text +from ansible.module_utils.common.dict_transformations import recursive_diff +from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils.six.moves.urllib.parse import urlencode + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +@pytest.fixture +def mock_get_token(mocker): + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), CONNECTION_DICT), + autospec=True, + ) + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count + ) + return object_with_future_response + + +def raise_404(url): + def _raise_404(): + raise HTTPError( + url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('') + ) + + return _raise_404 + + +@pytest.fixture +def mock_absent_url(mocker): + absent_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not_here': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not%20here': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': raise_404( + 'http://keycloak.url/auth/admin/realms/master/components/123-123' + ), + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'federation_id': 'not_here'}, + {'federation_id': 'not here'}, + {'federation_uuid': '123-123'}, + ], +) +def test_state_absent_should_not_create_absent_federation( + monkeypatch, mock_absent_url, mock_get_token, extra_arguments +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json[ + 'msg' + ] == 'Federation {} does not exist, doing nothing.'.format( + list(extra_arguments.values())[0] + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['ldap_federation'] + + +@pytest.fixture +def mock_delete_url(mocker): + # This fixture does not return a full federation json, just an extract + # with parts needed in the test and some value in order to have object + # organisation. + delete_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=ldap-to-delete': create_wrapper( + json.dumps( + [ + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': True}, + } + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': { + 'DELETE': None, + 'GET': create_wrapper( + json.dumps( + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': True}, + } + ) + ), + }, + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), delete_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [{'federation_id': 'ldap-to-delete'}, {'federation_uuid': '123-123'}], +) +def test_state_absent_should_delete_existing_federation( + monkeypatch, extra_arguments, mock_delete_url, mock_get_token +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Federation {} deleted.'.format( + list(extra_arguments.values())[0] + ) + assert ansible_exit_json['changed'] + assert not ansible_exit_json['ldap_federation'] + + +@pytest.fixture() +def mock_create_url(mocker): + create_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components/': None, + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), create_federation), + autospec=True, + ) + + +def test_state_present_should_create_absent_federation( + monkeypatch, mock_create_url, mock_get_token +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + 'vendor': 'other', + 'edit_mode': 'WRITABLE', + 'synchronize_registrations': True, + 'username_ldap_attribute': 'cn', + 'rdn_ldap_attribute': 'cn', + 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'connection_url': 'ldap://openldap', + 'users_dn': 'ou=People,dc=my-company', + 'bind_dn': 'cn=admin,dc=my-company', + 'bind_credential': 'ldap_admin_password', + 'uuid_ldap_attribute': 'entryUUID', + 'search_scope': 'subtree', + 'use_truststore_spi': 'never', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Federation company-ldap created.' + assert ansible_exit_json['changed'] + reference_result = { + 'config': { + 'bindDn': 'cn=admin,dc=my-company', + 'connectionPooling': False, + 'connectionUrl': 'ldap://openldap', + 'bindCredential': 'no_log', + 'editMode': 'WRITABLE', + 'priority': 0, + 'rdnLDAPAttribute': 'cn', + 'searchScope': 2, + 'synchronizeRegistrations': True, + 'useTruststoreSpi': 'never', + 'userObjectClasses': 'inetOrgPerson, organizationalPerson', + 'usernameLDAPAttribute': 'cn', + 'usersDn': 'ou=People,dc=my-company', + 'uuidLDAPAttribute': 'entryUUID', + 'vendor': 'other', + }, + 'name': 'company-ldap', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + } + diff_result = recursive_diff(ansible_exit_json['ldap_federation'], reference_result) + assert not diff_result + config = reference_result.pop('config') + send_config = {} + for key, value in config.items(): + if key == 'bindCredential': + send_config.update({'bindCredential': ['ldap_admin_password']}) + else: + send_config.update({key: [value]}) + reference_result.update({'config': send_config}) + create_call = mock_create_url.mock_calls[1] + send_json = json.loads(create_call.kwargs['data']) + diff_result = recursive_diff(send_json, reference_result) + assert not diff_result + + +def test_create_payload_all_mandatory(monkeypatch, mock_absent_url, mock_get_token): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'not_here', + } + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == ( + 'bind_credential, bind_dn, connection_url, rdn_ldap_attribute, ' + 'user_object_classes, username_ldap_attribute, users_dn, uuid_ldap_attribute ' + 'and vendor are missing for the federation creation.' + ) + + +@pytest.fixture() +def mock_create_url_with_check(mocker): + create_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components/': None, + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), create_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [{'test_connection': True}, {'test_authentication': True}], + ids=['connection only', 'authentication'], +) +def test_arguments_check_connectivity_should_try_ldap_connection( + monkeypatch, extra_arguments, mock_create_url_with_check, mock_get_token +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + 'vendor': 'other', + 'edit_mode': 'WRITABLE', + 'synchronize_registrations': True, + 'username_ldap_attribute': 'cn', + 'rdn_ldap_attribute': 'cn', + 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'connection_url': 'ldap://openldap', + 'users_dn': 'ou=People,dc=my-company', + 'bind_dn': 'cn=admin,dc=my-company', + 'bind_credential': 'ldap_admin_password', + 'uuid_ldap_attribute': 'entryUUID', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Federation company-ldap created.' + assert ansible_exit_json['changed'] + calls = mock_create_url_with_check.mock_calls + for one_call in filterfalse(lambda x: 'testLDAPConnection' not in x.args[0], calls): + send_data = one_call.kwargs['data'] + assert urlencode({'bindCredential': 'ldap_admin_password'}) in send_data + assert urlencode({'bindDn': 'cn=admin,dc=my-company'}) in send_data + + +def raise_400(url): + def _raise_400(): + raise HTTPError(url=url, code=400, msg='', hdrs='', fp=StringIO('')) + + return _raise_400 + + +@pytest.fixture() +def mock_wrong_authentication_url(mocker, request): + ldap_connection = { + 'wrong LDAP address': raise_400( + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection' + ), + 'wrong credentials': [ + None, + raise_400( + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection' + ), + ], + } + create_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': ldap_connection[ + request.node.callspec.id + ], + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), create_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments, waited_message', + [ + ( + { + 'connection_url': 'ldap://wrong.openldap', + 'bind_dn': 'cn=admin,dc=my-company', + 'bind_credential': 'ldap_admin_password', + }, + 'The url connection ldap://wrong.openldap cannot be reached.', + ), + ( + { + 'connection_url': 'ldap://openldap', + 'bind_dn': 'cn=admin,dc=my-company', + 'bind_credential': 'ldap_admin_password', + }, + ( + 'The user cn=admin,dc=my-company cannot logged in the ldap at ' + 'ldap://openldap, you should check your credentials.' + ), + ), + ], + ids=['wrong LDAP address', 'wrong credentials'], +) +def test_wrong_ldap_credentials_should_raise_an_error( + monkeypatch, + extra_arguments, + waited_message, + mock_wrong_authentication_url, + mock_get_token, +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + 'rdn_ldap_attribute': 'cn', + 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'username_ldap_attribute': 'cn', + 'users_dn': 'ou=People,dc=my-company', + 'uuid_ldap_attribute': 'entryUUID', + 'vendor': 'other', + 'test_authentication': True, + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == waited_message + + +@pytest.fixture() +def mock_update_url(mocker): + update_federation = { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps( + [ + { + 'id': '123-123', + 'name': 'company-ldap', + 'parentId': 'master', + 'config': {'pagination': True}, + } + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), update_federation), + autospec=True, + ) + + +def test_state_present_should_update_existing_federation( + monkeypatch, mock_get_token, mock_update_url +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + 'uuid_ldap_attribute': 'newEntryUUID', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Federation company-ldap updated.' + assert ansible_exit_json['changed'] + reference_result = { + 'config': { + 'uuidLDAPAttribute': 'newEntryUUID', + 'priority': 0, + 'connectionPooling': False, + }, + 'name': 'company-ldap', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + } + diff_result = recursive_diff(ansible_exit_json['ldap_federation'], reference_result) + assert not diff_result From 5112932e96272da24e80ae8d1ad48fd190587f40 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:42:04 +0200 Subject: [PATCH 17/79] refactor: delete duplicates function It surely coming from badly done rebase or merge --- .../identity/keycloak/keycloak.py | 912 +----------------- 1 file changed, 45 insertions(+), 867 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 2318e64bc0d453..1068f9032e10fe 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -608,100 +608,7 @@ def delete_scope_mapping(self, id, roles, id_client=None, target='client', realm except Exception as e: self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' % (id, realm, str(e))) - def get_groups(self, realm="master"): - """ Fetch the name and ID of all groups on the Keycloak server. - - To fetch the full data of the group, make a subsequent call to - get_group_by_groupid, passing in the ID of the group you wish to return. - - :param realm: Return the groups of this realm (default "master"). - """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) - try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, - validate_certs=self.validate_certs)) - except Exception as e: - self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" - % (realm, str(e))) - - def get_group_by_groupid(self, gid, realm="master"): - """ Fetch a keycloak group from the provided realm using the group's unique ID. - - If the group does not exist, None is returned. - - gid is a UUID provided by the Keycloak API - :param gid: UUID of the group to be returned - :param realm: Realm in which the group resides; default 'master'. - """ - groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) - try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, - validate_certs=self.validate_certs)) - - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) - except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) - - def get_group_by_name(self, name, realm="master"): - """ Fetch a keycloak group within a realm based on its name. - - The Keycloak API does not allow filtering of the Groups resource by name. - As a result, this method first retrieves the entire list of groups - name and ID - - then performs a second query to fetch the group. - - If the group does not exist, None is returned. - :param name: Name of the group to fetch. - :param realm: Realm in which the group resides; default 'master' - """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) - try: - all_groups = self.get_groups(realm=realm) - - for group in all_groups: - if group['name'] == name: - return self.get_group_by_groupid(group['id'], realm=realm) - - return None - - except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (name, realm, str(e))) - - def get_realm_roles_of_group(self, group_uuid, realm='master'): - effective_role_url = URL_EFFECTIVE_REALM_ROLE_IN_GROUP.format( - url=self.baseurl, - group_id=group_uuid, - realm=realm, - ) - try: - return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders, - validate_certs=self.validate_certs)) - except Exception as e: - self.module.fail_json( - msg="Could not fetch group role %s in realm %s: %s" % (group_uuid, realm, str(e))) - def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): - effective_role_url = URL_EFFECTIVE_CLIENT_ROLE_IN_GROUP.format( - url=self.baseurl, - realm=realm, - group_id=group_uuid, - client_uuid=client_uuid, - ) - try: - return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders, - validate_certs=self.validate_certs)) - except Exception as e: - self.module.fail_json( - msg="Could not fetch group role %s for client %s in realm %s: %s" % ( - group_uuid, client_uuid, realm, str(e))) def create_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): self._modify_link_between_group_and_role('POST', group_uuid, role, client_uuid, realm) @@ -732,72 +639,6 @@ def _modify_link_between_group_and_role( msg="Could not link {} and {}".format(group_uuid, role['id']) ) - def create_group(self, grouprep, realm="master"): - """ Create a Keycloak group. - - :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. - :return: HTTPResponse object on success - """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) - try: - return open_url(groups_url, method='POST', headers=self.restheaders.header, - data=json.dumps(grouprep), validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json(msg="Could not create group %s in realm %s: %s" - % (grouprep['name'], realm, str(e))) - - def update_group(self, grouprep, realm="master"): - """ Update an existing group. - - :param grouprep: A GroupRepresentation of the updated group. - :return HTTPResponse object on success - """ - group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) - - try: - return open_url(group_url, method='PUT', headers=self.restheaders.header, - data=json.dumps(grouprep), validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json(msg='Could not update group %s in realm %s: %s' - % (grouprep['name'], realm, str(e))) - - def delete_group(self, name=None, groupid=None, realm="master"): - """ Delete a group. One of name or groupid must be provided. - - Providing the group ID is preferred as it avoids a second lookup to - convert a group name to an ID. - - :param name: The name of the group. A lookup will be performed to retrieve the group ID. - :param groupid: The ID of the group (preferred to name). - :param realm: The realm in which this group resides, default "master". - """ - - if groupid is None and name is None: - # prefer an exception since this is almost certainly a programming error in the module itself. - raise Exception("Unable to delete group - one of group ID or name must be provided.") - - # only lookup the name if groupid isn't provided. - # in the case that both are provided, prefer the ID, since it's one - # less lookup. - if groupid is None and name is not None: - for group in self.get_groups(realm=realm): - if group['name'] == name: - groupid = group['id'] - break - - # if the group doesn't exist - no problem, nothing to delete. - if groupid is None: - return None - - # should have a good groupid by here. - group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) - try: - return open_url(group_url, method='DELETE', headers=self.restheaders.header, - 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_users(self, realm='master', filter=None): """ Obtains user representations for users in a realm @@ -1091,248 +932,70 @@ def update_role(self, role_id, role_representation, realm="master", client_uuid= role_url=role_url ) - def get_users(self, realm='master', filter=None): - """ Obtains client representations for clients in a realm + def get_groups(self, realm="master"): + """ Fetch the name and ID of all groups on the Keycloak server. - :param realm: realm to be queried - :param filter: if defined, only the user with userid specified in the filter is returned - :return: list of dicts of users representations - """ - userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) - if filter is not None: - userlist_url += '?userId=%s' % filter + To fetch the full data of the group, make a subsequent call to + get_group_by_groupid, passing in the ID of the group you wish to return. + :param realm: Return the groups of this realm (default "master"). + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - user_json = json.load( - open_url(userlist_url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' - % (realm, str(e))) + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) except Exception as e: - self.module.fail_json( - msg='Could not obtain list of clients for realm %s: %s' - % (realm, str(e))) + self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" + % (realm, str(e))) - def get_user_by_id(self, id, realm='master'): - """ Obtain client template representation by id + def get_group_by_groupid(self, gid, realm="master"): + """ Fetch a keycloak group from the provided realm using the group's unique ID. - :param id: id (not name) of client template to be queried - :param realm: client template from this realm - :return: dict of client template representation or None if none matching exist - """ - url = URL_USER.format(url=self.baseurl, id=id, realm=realm) + If the group does not exist, None is returned. + gid is a UUID provided by the Keycloak API + :param gid: UUID of the group to be returned + :param realm: Realm in which the group resides; default 'master'. + """ + groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.load( - open_url(url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + except HTTPError as e: if e.code == 404: return None else: - self.module.fail_json( - msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (gid, realm, str(e))) except Exception as e: - self.module.fail_json( - msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (gid, realm, str(e))) - def get_user_id(self, name, realm='master'): - """ Obtain user id by name + def get_group_by_name(self, name, realm="master"): + """ Fetch a keycloak group within a realm based on its name. - :param name: name of user to be queried - :param realm: client template from this realm - :return: user id (usually a UUID) + The Keycloak API does not allow filtering of the Groups resource by name. + As a result, this method first retrieves the entire list of groups - name and ID - + then performs a second query to fetch the group. + + If the group does not exist, None is returned. + :param name: Name of the group to fetch. + :param realm: Realm in which the group resides; default 'master' """ - result = self.get_user_by_name(name, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + all_groups = self.get_groups(realm=realm) - def get_user_by_name(self, name, realm='master'): - """ Obtain user representation by name + for group in all_groups: + if group['name'] == name: + return self.get_group_by_groupid(group['id'], realm=realm) - :param name: name of user to be queried - :param realm: user from this realm - :return: dict of user representation or None if none matching exist - """ - result = self.get_users(realm) - if isinstance(result, list): - result = [x for x in result if x['username'] == name] - if len(result) > 0: - return result[0] - return None + return None - def create_user(self, user_representation, realm="master"): - """ Create a user in keycloak - - :param user_representation: user representation of user to be created. Must at least contain field userId - :param realm: realm for user to be created - :return: HTTPResponse object on success - """ - # Keycloak wait username as key for the keycloak user username. For the - # keycloak modules, the username is an alias of the auth_username, thus - # cannot be used for the users. - try: - user_name = user_representation.pop('keycloakUsername') - except KeyError: - self.module.fail_json( - msg='User name needs to be specified when creating a new user', - user_representation=user_representation - ) - else: - user_representation.update({'username': user_name}) - user_url = URL_USERS.format(url=self.baseurl, realm=realm) - - try: - return open_url(user_url, method='POST', headers=self.restheaders, - data=json.dumps(user_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not create user %s in realm %s: %s' - % (user_representation['username'], realm, str(e)), - payload=user_representation) - - def update_user(self, uuid, user_representation, realm="master"): - """ Update an existing user - :param uuid: id of user to be updated in Keycloak - :param user_representation: corresponding (partial/full) user representation with updates - :param realm: realm the user is in - :return: HTTPResponse object on success - """ - # Keycloak response with an error 409 conflict if a username is send - # when updating an user. To avoid this, if the user was designated by - # its username, it is deleted. - try: - user_representation.pop('keycloakUsername') - except KeyError: - pass - try: - keycloak_attributes = user_representation.pop('keycloakAttributes') - except KeyError: - pass - else: - user_representation.update({'attributes': keycloak_attributes}) - - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) - - try: - return open_url(user_url, method='PUT', headers=self.restheaders, - data=json.dumps(user_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not update user %s in realm %s: %s' % ( - uuid, realm, str(e)), - user_representation=user_representation, - user_url=user_url - ) - - def delete_user(self, id, realm="master"): - """ Delete a user from Keycloak - - :param id: id (not userId) of user to be deleted - :param realm: realm of user to be deleted - :return: HTTPResponse object on success - """ - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) - - try: - return open_url(user_url, method='DELETE', - headers=self.restheaders, - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not delete user %s in realm %s: %s' - % (id, realm, str(e))) - - def get_json_from_url(self, url): - try: - user_json = json.load( - open_url(url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to get: %s' % ( - url)) - except Exception as e: - self.module.fail_json(msg='Could not obtain url: %s' % (url)) - - def get_groups(self, realm="master"): - """ Fetch the name and ID of all groups on the Keycloak server. - - To fetch the full data of the group, make a subsequent call to - get_group_by_groupid, passing in the ID of the group you wish to return. - - :param realm: Return the groups of this realm (default "master"). - """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) - try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, - validate_certs=self.validate_certs)) - except Exception as e: - self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" - % (realm, str(e))) - - def get_group_by_groupid(self, gid, realm="master"): - """ Fetch a keycloak group from the provided realm using the group's unique ID. - - If the group does not exist, None is returned. - - gid is a UUID provided by the Keycloak API - :param gid: UUID of the group to be returned - :param realm: Realm in which the group resides; default 'master'. - """ - groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) - try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, - validate_certs=self.validate_certs)) - - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) - except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (gid, realm, str(e))) - - def get_group_by_name(self, name, realm="master"): - """ Fetch a keycloak group within a realm based on its name. - - The Keycloak API does not allow filtering of the Groups resource by name. - As a result, this method first retrieves the entire list of groups - name and ID - - then performs a second query to fetch the group. - - If the group does not exist, None is returned. - :param name: Name of the group to fetch. - :param realm: Realm in which the group resides; default 'master' - """ - groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) - try: - all_groups = self.get_groups(realm=realm) - - for group in all_groups: - if group['name'] == name: - return self.get_group_by_groupid(group['id'], realm=realm) - - return None - - except Exception as e: - self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" - % (name, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (name, realm, str(e))) def get_realm_roles_of_group(self, group_uuid, realm='master'): effective_role_url = URL_EFFECTIVE_REALM_ROLE_IN_GROUP.format( @@ -1364,35 +1027,6 @@ def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): msg="Could not fetch group role %s for client %s in realm %s: %s" % ( group_uuid, client_uuid, realm, str(e))) - def create_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): - self._modify_link_between_group_and_role('POST', group_uuid, role, client_uuid, realm) - - def delete_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): - self._modify_link_between_group_and_role('DELETE', group_uuid, role, client_uuid, realm) - - def _modify_link_between_group_and_role( - self, method, group_uuid, role, client_uuid=None, realm='master'): - if client_uuid: - url = URL_CLIENT_ROLE_IN_GROUP.format( - url=self.baseurl, - realm=realm, - group_id=group_uuid, - client_uuid=client_uuid - ) - else: - url = URL_REALM_ROLE_IN_GROUP.format( - url=self.baseurl, - realm=realm, - group_id=group_uuid - ) - try: - return open_url(url=url, method=method, headers=self.restheaders, - validate_certs=self.validate_certs, data=json.dumps([role])) - except Exception as e: - self.module.fail_json( - msg="Could not link {} and {}".format(group_uuid, role['id']) - ) - def create_group(self, grouprep, realm="master"): """ Create a Keycloak group. @@ -1458,459 +1092,3 @@ def delete_group(self, name=None, groupid=None, realm="master"): except Exception as e: self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) - - def get_users(self, realm='master', filter=None): - """ Obtains user representations for users in a realm - - :param realm: realm to be queried - :param filter: if defined, only the user with userid specified in the filter is returned - :return: list of dicts of users representations - """ - userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) - if filter is not None: - userlist_url += '?userId=%s' % filter - - try: - user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' - % (realm, str(e))) - except Exception as e: - self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' - % (realm, str(e))) - - def get_user_by_id(self, id, realm='master'): - """ Obtain user representation by id - - :param id: id (not name) of user to be queried - :param realm: realm to be queried - :return: dict of user representation or None if none matching exists - """ - url = URL_USER.format(url=self.baseurl, id=id, realm=realm) - - try: - return json.load( - open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json(msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' - % (id, realm, str(e))) - except Exception as e: - self.module.fail_json( - msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) - - def get_user_id(self, name, realm='master'): - """ Obtain user id by name - - :param name: name of user to be queried - :param realm: realm to be queried - :return: user id (usually a UUID) - """ - result = self.get_user_by_name(name, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None - - def get_user_by_name(self, name, realm='master'): - """ Obtain user representation by name - - :param name: name of user to be queried - :param realm: user from this realm - :return: dict of user representation or None if none matching exist - """ - result = self.get_users(realm) - if isinstance(result, list): - result = [x for x in result if x['username'] == name] - if len(result) > 0: - return result[0] - return None - - def create_user(self, user_representation, realm="master"): - """ Create a user in keycloak - - :param user_representation: user representation of user to be created. Must at least contain field userId - :param realm: realm for user to be created - :return: HTTPResponse object on success - """ - # Keycloak wait username as key for the keycloak user username. For the - # keycloak modules, the username is an alias of the auth_username, thus - # cannot be used for the users. - try: - user_name = user_representation.pop('keycloakUsername') - except KeyError: - self.module.fail_json( - msg='User name needs to be specified when creating a new user', - user_representation=user_representation - ) - else: - user_representation.update({'username': user_name}) - user_url = URL_USERS.format(url=self.baseurl, realm=realm) - - try: - return open_url(user_url, method='POST', headers=self.restheaders, - data=json.dumps(user_representation), validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json(msg='Could not create user %s in realm %s: %s' - % (user_representation['username'], realm, str(e)), - payload=user_representation) - - def update_user(self, uuid, user_representation, realm="master"): - """ Update an existing user - :param uuid: id of user to be updated in Keycloak - :param user_representation: corresponding (partial/full) user representation with updates - :param realm: realm the user is in - :return: HTTPResponse object on success - """ - # Keycloak response with an error 409 conflict if a username is send - # when updating an user. To avoid this, if the user was designated by - # its username, it is deleted. - try: - user_representation.pop('keycloakUsername') - except KeyError: - pass - try: - keycloak_attributes = user_representation.pop('keycloakAttributes') - except KeyError: - pass - else: - user_representation.update({'attributes': keycloak_attributes}) - - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) - - try: - return open_url(user_url, method='PUT', headers=self.restheaders, - data=json.dumps(user_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not update user %s in realm %s: %s' % (uuid, realm, str(e)), - user_representation=user_representation, - user_url=user_url - ) - - def delete_user(self, id, realm="master"): - """ Delete a user from Keycloak - - :param id: id (not userId) of user to be deleted - :param realm: realm of user to be deleted - :return: HTTPResponse object on success - """ - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) - - try: - return open_url(user_url, method='DELETE', headers=self.restheaders, - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json(msg='Could not delete user %s in realm %s: %s' - % (id, realm, str(e))) - - def get_json_from_url(self, url): - try: - user_json = json.load(open_url(url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to get: %s' % (url)) - except Exception as e: - self.module.fail_json(msg='Could not obtain url: %s' % (url)) - - def get_role_url(self, role_id, realm='master', client_uuid=None): - if 'name' in role_id: - role_name = role_id['name'] - if client_uuid: - rolelist_url = URL_CLIENT_ROLE.format( - url=self.baseurl, realm=quote(realm), id=client_uuid, - role_id=quote(role_name)) - else: - rolelist_url = URL_REALM_ROLE.format( - url=self.baseurl, realm=quote(realm), role_id=quote(role_name)) - else: - rolelist_url = URL_REALM_ROLE_BY_ID.format( - url=self.baseurl, realm=realm, id=role_id['uuid']) - return rolelist_url - - def get_role(self, role_id, realm='master', client_uuid=None): - """ Obtain client template representation by id - :param role_id: id or name of role to be queried - :param realm: role from this realm - :return: dict of role representation or None if none matching exist - """ - role_url = self.get_role_url(role_id, realm, client_uuid) - - try: - return json.load( - open_url(role_url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json( - msg='Could not obtain role %s for realm %s: %s' - % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain role %s for realm %s: %s' - % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) - except Exception as e: - self.module.fail_json( - msg='Could not obtain role %s for realm %s: %s' - % (to_text(list(role_id.values())[0]), to_text(realm), to_text(e))) - - def delete_role(self, role_id, realm="master"): - """ Delete a role from Keycloak - :param role_id: id of role (uuid or name) to be deleted - :param realm: realm of role to be deleted - :return: HTTPResponse object on success - """ - role_url = URL_REALM_ROLE_BY_ID.format(url=self.baseurl, realm=quote(realm), id=quote(role_id)) - - 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='Could not delete role %s in realm %s: %s' - % (role_id, realm, to_text(e))) - - def get_role_id(self, name, realm='master', client_uuid=None): - """ Obtain role id by name - :param name: name of role to be queried - :param realm: realm to be queried - :return: role id (usually a UUID) - """ - result = self.get_role(name, realm, client_uuid) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None - - def create_role(self, role_representation, realm="master", client_uuid=None): - """ Create a role in keycloak - :param role_representation: role representation to be created. - :param realm: realm for role to be created - :return: HTTPResponse object on success - """ - if client_uuid: - role_url = URL_CLIENT_ROLES.format( - url=self.baseurl, realm=quote(realm), id=client_uuid) - role_representation.pop('clientId') - else: - role_url = URL_REALM_ROLES.format(url=self.baseurl, realm=quote(realm)) - - try: - return open_url(role_url, method='POST', headers=self.restheaders, - data=json.dumps(role_representation), validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not create role %s in realm %s: %s' - % (to_text(role_representation['name']), to_text(realm), to_text(e)), - payload=role_representation) - - def update_role(self, role_id, role_representation, realm="master", client_uuid=None): - """ Update an existing role - :param role_id: id of role to be updated in Keycloak - :param role_representation: corresponding (partial/full) role representation with updates - :param realm: realm the role is in - :return: HTTPResponse object on success - """ - role_url = self.get_role_url(role_id, realm, client_uuid) - - try: - return open_url(role_url, method='PUT', headers=self.restheaders, - data=json.dumps(role_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not update role %s in realm %s: %s' % ( - to_text(list(role_id.values())[0]), to_text(realm), to_text(e)), - role_representation=role_representation, - role_url=role_url - ) - - def get_users(self, realm='master', filter=None): - """ Obtains client representations for clients in a realm - - :param realm: realm to be queried - :param filter: if defined, only the user with userid specified in the filter is returned - :return: list of dicts of users representations - """ - userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) - if filter is not None: - userlist_url += '?userId=%s' % filter - - try: - user_json = json.load( - open_url(userlist_url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' - % (realm, str(e))) - except Exception as e: - self.module.fail_json( - msg='Could not obtain list of clients for realm %s: %s' - % (realm, str(e))) - - def get_user_by_id(self, id, realm='master'): - """ Obtain client template representation by id - - :param id: id (not name) of client template to be queried - :param realm: client template from this realm - :return: dict of client template representation or None if none matching exist - """ - url = URL_USER.format(url=self.baseurl, id=id, realm=realm) - - try: - return json.load( - open_url(url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json( - msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to obtain user %s for realm %s: %s' - % (id, realm, str(e))) - except Exception as e: - self.module.fail_json( - msg='Could not obtain user %s for realm %s: %s' - % (id, realm, str(e))) - - def get_user_id(self, name, realm='master'): - """ Obtain user id by name - - :param name: name of user to be queried - :param realm: client template from this realm - :return: user id (usually a UUID) - """ - result = self.get_user_by_name(name, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None - - def get_user_by_name(self, name, realm='master'): - """ Obtain user representation by name - - :param name: name of user to be queried - :param realm: user from this realm - :return: dict of user representation or None if none matching exist - """ - result = self.get_users(realm) - if isinstance(result, list): - result = [x for x in result if x['username'] == name] - if len(result) > 0: - return result[0] - return None - - def create_user(self, user_representation, realm="master"): - """ Create a user in keycloak - - :param user_representation: user representation of user to be created. Must at least contain field userId - :param realm: realm for user to be created - :return: HTTPResponse object on success - """ - # Keycloak wait username as key for the keycloak user username. For the - # keycloak modules, the username is an alias of the auth_username, thus - # cannot be used for the users. - try: - user_name = user_representation.pop('keycloakUsername') - except KeyError: - self.module.fail_json( - msg='User name needs to be specified when creating a new user', - user_representation=user_representation - ) - else: - user_representation.update({'username': user_name}) - user_url = URL_USERS.format(url=self.baseurl, realm=realm) - - try: - return open_url(user_url, method='POST', headers=self.restheaders, - data=json.dumps(user_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not create user %s in realm %s: %s' - % (user_representation['username'], realm, str(e)), - payload=user_representation) - - def update_user(self, uuid, user_representation, realm="master"): - """ Update an existing user - :param uuid: id of user to be updated in Keycloak - :param user_representation: corresponding (partial/full) user representation with updates - :param realm: realm the user is in - :return: HTTPResponse object on success - """ - # Keycloak response with an error 409 conflict if a username is send - # when updating an user. To avoid this, if the user was designated by - # its username, it is deleted. - try: - user_representation.pop('keycloakUsername') - except KeyError: - pass - try: - keycloak_attributes = user_representation.pop('keycloakAttributes') - except KeyError: - pass - else: - user_representation.update({'attributes': keycloak_attributes}) - - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) - - try: - return open_url(user_url, method='PUT', headers=self.restheaders, - data=json.dumps(user_representation), - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not update user %s in realm %s: %s' % ( - uuid, realm, str(e)), - user_representation=user_representation, - user_url=user_url - ) - - def delete_user(self, id, realm="master"): - """ Delete a user from Keycloak - - :param id: id (not userId) of user to be deleted - :param realm: realm of user to be deleted - :return: HTTPResponse object on success - """ - user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) - - try: - return open_url(user_url, method='DELETE', - headers=self.restheaders, - validate_certs=self.validate_certs) - except Exception as e: - self.module.fail_json( - msg='Could not delete user %s in realm %s: %s' - % (id, realm, str(e))) - - def get_json_from_url(self, url): - try: - user_json = json.load( - open_url(url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) - return user_json - except ValueError as e: - self.module.fail_json( - msg='API returned incorrect JSON when trying to get: %s' % ( - url)) - except Exception as e: - self.module.fail_json(msg='Could not obtain url: %s' % (url)) From 28575d4dfd24a17c3fc5f58edc92a53d791eb50c Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:44:44 +0200 Subject: [PATCH 18/79] refactor: apply auth header to scope module --- .../module_utils/identity/keycloak/keycloak.py | 10 +++++----- .../identity/keycloak/keycloak_client_scope.py | 14 +++++++++++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 1068f9032e10fe..bfa46e5a5d23cf 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -499,7 +499,7 @@ def get_scope_mappings(self, id, target='client', realm='master'): url = URL_CLIENT_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mappings for %s in realm %s: %s' @@ -525,7 +525,7 @@ def get_scope_mapping(self, id, id_client=None, target='client', realm='master') url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mapping for %s in realm %s: %s' @@ -552,7 +552,7 @@ def get_available_roles(self, id, id_client=None, target='client', realm='master url = URL_CLIENT_SCOPE_MAPPINGS_REALM_AVAILABLE.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain available roles for realm %s: %s' @@ -579,7 +579,7 @@ def create_scope_mapping(self, id, roles, id_client=None, target='client', realm url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return open_url(url, method='POST', headers=self.restheaders, data=json.dumps(roles), + return open_url(url, method='POST', headers=self.restheaders.header, data=json.dumps(roles), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' @@ -603,7 +603,7 @@ def delete_scope_mapping(self, id, roles, id_client=None, target='client', realm url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return open_url(url, method='DELETE', headers=self.restheaders, data=json.dumps(roles), + return open_url(url, method='DELETE', headers=self.restheaders.header, data=json.dumps(roles), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py index 0eb2c9c049ff3b..aba2891af8619d 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py @@ -5,6 +5,7 @@ # 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 ANSIBLE_METADATA = { @@ -278,6 +279,7 @@ from copy import deepcopy from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec, check_role_representation from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader def contains_role(roles, rolename): @@ -326,7 +328,17 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + # Obtain access token, initialize API + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) # Initialize some general variables realm = module.params.get('realm') From 9088a0602c532a9067801323f3a0b1a7c94a54e7 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:46:41 +0200 Subject: [PATCH 19/79] refactor: apply auth header to group-role mapping --- .../module_utils/identity/keycloak/keycloak.py | 7 +++---- .../identity/keycloak/keycloak_group_role_mapping.py | 12 +++++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index bfa46e5a5d23cf..df0ca53e2238c7 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -609,7 +609,6 @@ def delete_scope_mapping(self, id, roles, id_client=None, target='client', realm self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' % (id, realm, str(e))) - def create_link_between_group_and_role(self, group_uuid, role, client_uuid=None, realm='master'): self._modify_link_between_group_and_role('POST', group_uuid, role, client_uuid, realm) @@ -632,7 +631,7 @@ def _modify_link_between_group_and_role( group_id=group_uuid ) try: - return open_url(url=url, method=method, headers=self.restheaders, + return open_url(url=url, method=method, headers=self.restheaders.header, validate_certs=self.validate_certs, data=json.dumps([role])) except Exception as e: self.module.fail_json( @@ -1005,7 +1004,7 @@ def get_realm_roles_of_group(self, group_uuid, realm='master'): ) try: return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders, + headers=self.restheaders.header, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json( @@ -1020,7 +1019,7 @@ def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): ) try: return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders, + headers=self.restheaders.header, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json( diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py index e878a8ce28a679..2356d059ab4a47 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py @@ -146,6 +146,7 @@ from ansible.module_utils._text import to_text from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader def run_module(): @@ -182,7 +183,16 @@ def run_module(): realm = module.params.get('realm') state = module.params.get('state') result = {} - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) given_role_id = {'name': module.params.get('role_name')} if not given_role_id['name']: From 43e7a9a16bbe36ae88b4cde17a3d12308eed7b54 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:48:33 +0200 Subject: [PATCH 20/79] refactor: apply auth header to realm module --- .../module_utils/identity/keycloak/keycloak.py | 8 ++++---- .../modules/identity/keycloak/keycloak_realm.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index df0ca53e2238c7..88cb98e44dce60 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -426,7 +426,7 @@ def get_realm_by_name(self, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders, + return json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: @@ -450,7 +450,7 @@ def create_realm(self, realmrep): url = URL_REALMS.format(url=self.baseurl) try: - return open_url(url, method='POST', headers=self.restheaders, + return open_url(url, method='POST', headers=self.restheaders.header, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create realm: %s' @@ -466,7 +466,7 @@ def update_realm(self, realmrep, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='PUT', headers=self.restheaders, + return open_url(url, method='PUT', headers=self.restheaders.header, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update realm %s: %s' @@ -481,7 +481,7 @@ def delete_realm(self, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='DELETE', headers=self.restheaders, + return open_url(url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete realm %s: %s' diff --git a/lib/ansible/modules/identity/keycloak/keycloak_realm.py b/lib/ansible/modules/identity/keycloak/keycloak_realm.py index 96c84b66cba360..70f1f61893f682 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_realm.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_realm.py @@ -834,6 +834,7 @@ from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader def main(): @@ -944,7 +945,16 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) state = module.params.get('state') realm = module.params.get('name') if module.params.get('name') is not None else module.params.get('realm') From 600fb2bddc24f15b37869c583178c4d6aaca19e7 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:52:39 +0200 Subject: [PATCH 21/79] refactor: apply auth header to role module --- .../module_utils/identity/keycloak/keycloak.py | 8 ++++---- .../modules/identity/keycloak/keycloak_role.py | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 88cb98e44dce60..f1b5343a79d781 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -843,7 +843,7 @@ def get_role(self, role_id, realm='master', client_uuid=None): try: return json.load( - open_url(role_url, method='GET', headers=self.restheaders, + open_url(role_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: if e.code == 404: @@ -870,7 +870,7 @@ def delete_role(self, role_id, realm="master"): role_url = URL_REALM_ROLE_BY_ID.format(url=self.baseurl, realm=quote(realm), id=quote(role_id)) try: - return open_url(role_url, method='DELETE', headers=self.restheaders, + return open_url(role_url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete role %s in realm %s: %s' @@ -902,7 +902,7 @@ def create_role(self, role_representation, realm="master", client_uuid=None): role_url = URL_REALM_ROLES.format(url=self.baseurl, realm=quote(realm)) try: - return open_url(role_url, method='POST', headers=self.restheaders, + return open_url(role_url, method='POST', headers=self.restheaders.header, data=json.dumps(role_representation), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json( @@ -920,7 +920,7 @@ def update_role(self, role_id, role_representation, realm="master", client_uuid= role_url = self.get_role_url(role_id, realm, client_uuid) try: - return open_url(role_url, method='PUT', headers=self.restheaders, + return open_url(role_url, method='PUT', headers=self.restheaders.header, data=json.dumps(role_representation), validate_certs=self.validate_certs) except Exception as e: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_role.py b/lib/ansible/modules/identity/keycloak/keycloak_role.py index 03852d999dabb4..8581a4868c57fe 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_role.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -176,9 +176,12 @@ ''' from ansible.module_utils._text import to_text -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, camel, keycloak_argument_spec, KeycloakAuthorizationHeader, +) from ansible.module_utils.basic import AnsibleModule + AUTHORIZED_ATTRIBUTE_VALUE_TYPE = (str, int, float, bool) @@ -219,7 +222,16 @@ def run_module(): 'Attributes are not in the correct format. Should be a dictionary with ' 'one value per key as string, integer and boolean')) - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) before_role, client_uuid = get_initial_role(given_role_id, kc, realm, client_id) result = create_result(before_role, module) From 516e02e519edfab9820108d6377c7954c54f189b Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:55:36 +0200 Subject: [PATCH 22/79] refactor: apply auth header to user module --- .../module_utils/identity/keycloak/keycloak.py | 12 ++++++------ .../modules/identity/keycloak/keycloak_user.py | 15 +++++++++++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index f1b5343a79d781..6477d30a4562b2 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -650,7 +650,7 @@ def get_users(self, realm='master', filter=None): userlist_url += '?userId=%s' % filter try: - user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders, + user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) return user_json except ValueError as e: @@ -671,7 +671,7 @@ def get_user_by_id(self, id, realm='master'): try: return json.load( - open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) + open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: if e.code == 404: return None @@ -737,7 +737,7 @@ def create_user(self, user_representation, realm="master"): user_representation = self._put_values_in_list(user_representation, ['credentials']) try: - return open_url(user_url, method='POST', headers=self.restheaders, + return open_url(user_url, method='POST', headers=self.restheaders.header, data=json.dumps(user_representation), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create user %s in realm %s: %s' @@ -763,7 +763,7 @@ def update_user(self, uuid, user_representation, realm="master"): user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) try: - return open_url(user_url, method='PUT', headers=self.restheaders, + return open_url(user_url, method='PUT', headers=self.restheaders.header, data=json.dumps(user_representation), validate_certs=self.validate_certs) except Exception as e: @@ -802,7 +802,7 @@ def delete_user(self, id, realm="master"): user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(user_url, method='DELETE', headers=self.restheaders, + return open_url(user_url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete user %s in realm %s: %s' @@ -810,7 +810,7 @@ def delete_user(self, id, realm="master"): def get_json_from_url(self, url): try: - user_json = json.load(open_url(url, method='GET', headers=self.restheaders, + user_json = json.load(open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) return user_json except ValueError as e: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index 053f6a78901efc..b5e7e3acda4d8c 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -198,7 +198,9 @@ } ''' -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, camel, keycloak_argument_spec, KeycloakAuthorizationHeader +) from ansible.module_utils.basic import AnsibleModule @@ -265,7 +267,16 @@ def run_module(): 'Attributes are not in the correct format. Should be a dictionary with ' 'one value per key as string, integer and boolean')) - kc = KeycloakAPI(module) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + kc = KeycloakAPI(module, connection_header) before_user = get_initial_user(given_user_id, kc, realm) result = create_result(before_user, module) From b878b36667269d19039a134f002fe0ba8cef4124 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:56:25 +0200 Subject: [PATCH 23/79] refactor: change import for client scope module --- .../modules/identity/keycloak/keycloak_client_scope.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py index aba2891af8619d..a31e74aa0ab525 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py @@ -277,9 +277,10 @@ import json from copy import deepcopy -from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec, check_role_representation from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader +from ansible.module_utils.identity.keycloak.keycloak import( + KeycloakAPI, keycloak_argument_spec, check_role_representation, KeycloakAuthorizationHeader +) def contains_role(roles, rolename): From 83f9f9389227a4d28dfcc3b78540dfbbe4d6dfb7 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:57:11 +0200 Subject: [PATCH 24/79] refactor: change import for group module --- lib/ansible/module_utils/identity/keycloak/keycloak.py | 10 +++++----- .../identity/keycloak/keycloak_group_role_mapping.py | 5 +++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 6477d30a4562b2..d2f8c795f2e237 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -941,7 +941,7 @@ def get_groups(self, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" @@ -958,7 +958,7 @@ def get_group_by_groupid(self, gid, realm="master"): """ groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, validate_certs=self.validate_certs)) except HTTPError as e: @@ -1034,7 +1034,7 @@ def create_group(self, grouprep, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return open_url(groups_url, method='POST', headers=self.restheaders, + return open_url(groups_url, method='POST', headers=self.restheaders.header, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg="Could not create group %s in realm %s: %s" @@ -1049,7 +1049,7 @@ def update_group(self, grouprep, realm="master"): group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) try: - return open_url(group_url, method='PUT', headers=self.restheaders, + return open_url(group_url, method='PUT', headers=self.restheaders.header, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update group %s in realm %s: %s' @@ -1086,7 +1086,7 @@ def delete_group(self, name=None, groupid=None, realm="master"): # should have a good groupid by here. group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) try: - return open_url(group_url, method='DELETE', headers=self.restheaders, + return open_url(group_url, method='DELETE', headers=self.restheaders.header, validate_certs=self.validate_certs) except Exception as e: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py index 2356d059ab4a47..90b7ce99c6c7f3 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py @@ -145,8 +145,9 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_text -from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec -from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAuthorizationHeader, KeycloakAPI, keycloak_argument_spec +) def run_module(): From 6166127326e1602001d72b97477b6e1afce47aee Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 13:58:05 +0200 Subject: [PATCH 25/79] refactor: change import for realm module --- lib/ansible/modules/identity/keycloak/keycloak_realm.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_realm.py b/lib/ansible/modules/identity/keycloak/keycloak_realm.py index 70f1f61893f682..3fe5ed72ae0931 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_realm.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_realm.py @@ -832,9 +832,10 @@ } ''' -from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.identity.keycloak.keycloak import KeycloakAuthorizationHeader +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAuthorizationHeader, KeycloakAPI, camel, keycloak_argument_spec +) def main(): From 6b32969b2f893b8a922610c79a48c9e6035e5b50 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 13 Jun 2019 14:40:08 +0200 Subject: [PATCH 26/79] doc: add playbook example --- .../keycloak/keycloak_ldap_federation.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index d275da9e3009e1..afdf533ac86d61 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -13,6 +13,32 @@ 'supported_by': 'community', } +EXAMPLES = r''' +- name: Create a keycloak federation + keycloak_ldap_federation: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: my-company-ldap + state: present + edit_mode: WRITABLE + synchronize_registrations: True, + username_ldap_attribute: cn + rdn_ldap_attribute: cn + user_object_classes: inetOrgPerson, organizationalPerson + connection_url: ldap://openldap + users_dn: ou=People,dc=my-company + bind_dn: cn=admin,dc=my-company + bind_credential: ldap_admin_password + uuid_ldap_attribute: entryUUID + search_scope: subtree + use_truststore_spi: never + test_authentication: True +''' + import json from copy import deepcopy from ansible.module_utils._text import to_text From 5ede6439c12774407e7fa5895ebf131aebfb9fb1 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 10:37:41 +0200 Subject: [PATCH 27/79] test: change patched function for group-role mapping --- .../identity/keycloak/test_group_role_mapping.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_group_role_mapping.py b/test/units/modules/identity/keycloak/test_group_role_mapping.py index bc5313d337e473..0d6f6485299441 100644 --- a/test/units/modules/identity/keycloak/test_group_role_mapping.py +++ b/test/units/modules/identity/keycloak/test_group_role_mapping.py @@ -88,7 +88,7 @@ def mock_doing_nothing_urls(mocker): json.dumps([])), }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), doing_nothing_urls), autospec=True ) @@ -150,7 +150,7 @@ def mock_creation_url(mocker): json.dumps({'id': '222-222', 'name': 'one_role'})), }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), creation_urls), autospec=True ) @@ -208,7 +208,7 @@ def existing_nothing_to_do(mocker): ), }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), nothing_to_do_url), autospec=True ) @@ -264,7 +264,7 @@ def to_delete(mocker): ) }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), delete_urls), autospec=True ) @@ -317,7 +317,7 @@ def wrong_parameter_url(mocker): 'http://keycloak.url/auth/admin/realms/master/clients/333-333/roles/doesnotexist': raise_404('333-333/roles/doesnotexist'), }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), wrong_parameter_urls), autospec=True ) From ec064bda191078cb5a9ee01b6be949a3e1a23c1d Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 10:38:55 +0200 Subject: [PATCH 28/79] test: change patched function for role module --- .../modules/identity/keycloak/test_keycloak_role.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_role.py b/test/units/modules/identity/keycloak/test_keycloak_role.py index 59a85c231691fa..a4c944a58d7616 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_role.py +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -126,7 +126,7 @@ def mock_absent_role_url(mocker): }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), absent_role_url), autospec=True ) @@ -196,7 +196,7 @@ def mock_already_here_role_in_client_url(mocker): ))) }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), already_here_role_url), autospec=True ) @@ -247,7 +247,7 @@ def mock_delete_role_urls(mocker): } }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), delete_role_urls), autospec=True ) @@ -331,7 +331,7 @@ def mock_create_role_urls(mocker): }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), create_role_urls), autospec=True ) @@ -396,7 +396,7 @@ def mock_update_role_urls(mocker): } }) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), update_role_urls), autospec=True ) From 452f0259b41ddb63ceee5a91a6f65e5ff3f6ae8a Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 10:40:06 +0200 Subject: [PATCH 29/79] test: change patched function for user module --- test/units/modules/identity/keycloak/test_keycloak_user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_user.py b/test/units/modules/identity/keycloak/test_keycloak_user.py index 6aad6fe130b1bf..09fda1b45629b0 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_user.py +++ b/test/units/modules/identity/keycloak/test_keycloak_user.py @@ -164,7 +164,7 @@ @pytest.fixture def url_mock_keycloak(mocker): return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), RESPONSE_ADMIN_ONLY), autospec=True ) @@ -313,7 +313,7 @@ def build_user_update_request(request): def dynamic_url_for_user_update(mocker, build_user_update_request): parameters, response_dictionary = build_user_update_request return parameters, mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), response_dictionary), autospec=True ) @@ -376,7 +376,7 @@ def url_for_fake_update(mocker): 'GET': create_wrapper(UPDATED_USER) }}) return mocker.patch( - 'ansible.module_utils.keycloak.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), new_response_dictionary), autospec=True ) From 2c15fc7b401cdd125e8f4b24e47a47fd7a368ad1 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 11:21:13 +0200 Subject: [PATCH 30/79] doc: LdapFederation class --- .../keycloak/keycloak_ldap_federation.py | 90 +++++++++++++++++-- 1 file changed, 84 insertions(+), 6 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index afdf533ac86d61..0525577fcc1c92 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -63,6 +63,8 @@ class LdapFederation(object): + """Keycloak LDAP Federation class. + """ def __init__(self, module, connection_header): self.module = module self.restheaders = connection_header @@ -73,6 +75,10 @@ def __init__(self, module, connection_header): self.uuid = '' def _get_federation_url(self): + """Create the url in order to get the federation from the given argument (uuid or name) + :return: the url as string + :rtype: str + """ try: return USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), @@ -93,6 +99,12 @@ def _get_federation_url(self): ) def get_federation(self): + """Get the federation information from keycloak + + :return: the federation representation as a dictionary, if the asked + representation does not exist, a empty dictionary is returned. + :rtype: dict + """ get_url = self._get_federation_url() realm = self.module.params.get('realm') try: @@ -135,11 +147,17 @@ def get_federation(self): @property def given_id(self): + """Get the asked id given by the user. + + :return the asked id given by the user as a name or an uuid. + :rtype: str + """ if self.module.params.get('federation_id'): return self.module.params.get('federation_id') return self.module.params.get('federation_uuid') def delete(self): + """Delete the federation""" federation_url = self._get_federation_url() try: open_url( @@ -155,7 +173,12 @@ def delete(self): ) def update(self): - federation_payload = self.create_payload() + """Update the federation + + :return: the representation of the updated federation + :rtype: dict + """ + federation_payload = self._create_payload() put_url = USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), @@ -182,7 +205,17 @@ def update(self): return self._clean_payload(federation_payload) def create(self): - federation_payload = self.create_payload() + """Create the federation from the given arguments. + + Before create the federation, there is a check concerning the mandatory + arguments waited by keycloak. + If asked by the user, before creating the federation, the connection or + the authentication can be tested. + + :return: the representation of the updated federation + :rtype: dict + """ + federation_payload = self._create_payload() self.check_mandatory_arguments(federation_payload) post_url = COMPONENTS_URL.format( url=self.module.params.get('auth_keycloak_url'), @@ -209,6 +242,7 @@ def create(self): return self._clean_payload(federation_payload) def _test_connection(self): + """Test the connection to the LDAP server""" if not self._call_test_url({'action': 'testConnection'}): self.module.fail_json( msg='The url connection %s cannot be reached.' @@ -216,6 +250,7 @@ def _test_connection(self): ) def _test_authentication(self): + """Test the authentication to the LDAP server with the given binding credentials.""" if not self._call_test_url({'action': 'testAuthentication'}): self.module.fail_json( msg='The user %s cannot logged in the ldap at %s, ' @@ -227,6 +262,19 @@ def _test_authentication(self): ) def _call_test_url(self, extra_arguments): + """Call the keycloak url testing credentials against the LDAP server. + + The same url is called for connection and authentication, only the + extra_arguments given by calling function is necessary for changing + the tested functionality. + The connection or authentication failure is identified with the 400 + http status code. + + :param extra_arguments: a dictionary with the action to do ( + authentication or connection) + :return: a boolean showing if the connection or the authentication + works. + """ payload = { 'bindCredential': self.module.params.get('bind_credential', ''), 'bindDn': self.module.params.get('bind_dn', ''), @@ -266,7 +314,20 @@ def _call_test_url(self, extra_arguments): ) return True - def create_payload(self): + def _create_payload(self): + """Create the payload for updating or creating a LDAP federation. + + Keycloak is waiting for a particular type of json for a LDAP federation: + { + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + 'config': {user parameters} + }. + And all user parameters must be in a list of one element. + + :return: the payload to put in the post or put request. + :rtype: dict + """ translation = {'federation_id': 'name', 'federation_uuid': 'id'} config = {} payload = { @@ -296,10 +357,21 @@ def create_payload(self): return payload def get_result(self): - return self._clean_payload(self.create_payload()) + """Get the payload cleaned of credentials and lists. + + :return: the cleaned payload + :rtype: dict + """ + return self._clean_payload(self._create_payload()) @staticmethod def _clean_payload(payload): + """Clean the payload from credentials and extra list. + + :param payload: the payload given to the post or put request. + :return: the cleaned payload + :rtype: dict + """ clean_payload = deepcopy(payload) old_config = clean_payload.pop('config') new_config = {} @@ -312,6 +384,12 @@ def _clean_payload(payload): return clean_payload def check_mandatory_arguments(self, creation_payload): + """Check if mandatory arguments for federation creation are present. + + If there are not present, this function exits the module with a fail_json. + + :param creation_payload: the payload to send to the post request. + """ mandatory_elements = [ 'priority', 'vendor', @@ -460,7 +538,7 @@ def run_module(): if not module.check_mode: payload = ldap_federation.create() else: - payload = ldap_federation.create_payload() + payload = ldap_federation.get_result() result['msg'] = to_text( 'Federation {given_id} created.'.format( @@ -473,7 +551,7 @@ def run_module(): if not module.check_mode: payload = ldap_federation.update() else: - payload = ldap_federation.create_payload() + payload = ldap_federation.get_result() result['msg'] = to_text( 'Federation {given_id} updated.'.format( given_id=ldap_federation.given_id From 2fe6af2f05042a3b0dcfbf9d9de2fb22ff1b54d2 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 16:36:17 +0200 Subject: [PATCH 31/79] doc: add ansible documentation variables --- .../keycloak/keycloak_ldap_federation.py | 481 ++++++++++++++++++ 1 file changed, 481 insertions(+) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 0525577fcc1c92..d7d1bef83bf7c5 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -13,6 +13,218 @@ 'supported_by': 'community', } +DOCUMENTATION = r''' +--- +module: keycloak_ldap_federation + +short_description: Allows administration of Keycloak LDAP federation via Keycloak API + +description: + - This module allows you to add, remove or modify Keycloak LDAP federation via the Keycloak 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(http://www.keycloak.org/docs-api/3.3/rest-api/). + + - At creation and update, this module allows you to test the connection or the authentication + to the LDAP service from the given arguments. If the connection or the authentication does + not work, the module fails. + + - When updating a LDAP federation, where possible provide the group ID to the module. + This removes a lookup to the API to translate the name into the group ID. + +version_added: "2.9" + +options: + state: + description: + - State of the LDAP federation. + - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the group will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP federation resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federationin the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with federation_uuid and one + of them is required by the module + type: str + aliases: [ federerationId ] + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with federation_id and one + of them is required by the module + type: str + aliases: [ federationUuid ] + + enable: + description: + - whether the federation will be enable + type: bool + + pagination: + description: + - Does the LDAP server supports pagination. + type: bool + + vendor: + description: + - LDAP provider + - Mandatory when creating the LDAP federation + choices: + - other + - ad + - rhds + - tivoli + - edirectory + type: str + + username_ldap_attribute: + description: + - Name of the LDAP attribute to map to the Keycloak username + - Mandatory when creating the LDAP federation + type: str + aliases: [ usernameLDAPAttribute, username_LDAP_attribute, usernameLdapAttribute ] + + rdn_ldap_attribute: + description: + - Name of the LDAP attribute to use as top attribute + - Mandatory when creating the LDAP federation + type: str + aliases: [ rdnLDAPAttribute, rdnLdapAttribute, rdn_LDAP_attribute ] + + user_object_classes: + description: + - LDAP object class attributes for users + - Mandatory when creating the LDAP federation + type: list + aliases: userObjectClasses + + connection_url: + description: + - the url of the LDAP service + - Mandatory when creating the LDAP federation + type: str + aliases: [ connectionUrl ] + + users_dn: + description: + - Full DN of LDAP tree where users are + - Mandatory when creating the LDAP federation + type: str + aliases: [ usersDn ] + + bind_dn: + description: + - DN of LDAP admin + - Mandatory when creating the LDAP federation + type: str + aliases: [ bindDn ] + + bind_credential: + description: + - Password of LDAP admin + - Mandatory when creating the LDAP federation + type: str + aliases: [ bindCredential ] + + uuid_ldap_attribute: + description: + - Name of LDAP attribute which is used as unique object identifier + for object in LDAP + - Mandatory when creating the LDAP federation + type: str + aliases: [ uuidLDAPAttribute, uuidLdapAttribute, uuid_LDAP_attribute ] + + edit_mode: + description: + - The behaviour of the Keycloak with the LDAP. + choices: + - READ_ONLY + - UNSYNCED + - WRITABLE + type: str + aliases: [ editMode ] + + import_enable: + description: + - Whether to import the user from the LDAP into the Keycloak databases + type: bool + aliases: [ importEnable ] + + synchronize_registrations: + description: + - Should new user in the Keycloak be created within the LDAP + type: bool + aliases: [ sync_registrations, synchronizeRegistrations, syncRegistrations ] + + customer_user_ldap_filter: + description: + - Filter for searching user in the LDAP + type: str + aliases: [ customUserSearchFilter, custom_user_search_filter, customUserLdapFilter, customUserLDAPFilter, customUserLDAPFilter ] + + search_scope: + description: + - Set how users are search, on one level or in all the subtree + type: str + choices: + - one level + - subtree + aliases: [ searchScope ] + + use_trustore_spi: + description: + - Whether LDAP connection will use the trustore SPI with the trustore conifgure in the standalone.xml + type: str + choices: + - ldapsOnly + - always + - never + aliases: [ useTruststoreSpi ] + + test_connection: + description: + - Check the connection to the LDAP server with a ping + - This parameter is mutually exclusive with test_authentication + type: bool + aliases: [ testConnection ] + + test_authentication: + description: + - Check the connection to the LDAP server with the admin credentials + - This parameter is mutually exclusive with test_connection + type: bool + aliases: [ testAuthentication ] + +notes: + - The following parameters existing in the UI are not taken into account in this module, I(importUser), I(validatePasswordPolicy), I(connectionPooling) (and all associated parameters), I(connectionTimeout), I(readTimeout), I(allowKerberosAuthentication) (and all associated parameters), I(useKerberosForPasswordAuthentication), I(batchSize), I(periodicFullSync), I(periodicChangedUserSync) and I(cachePolicy). + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + EXAMPLES = r''' - name: Create a keycloak federation keycloak_ldap_federation: @@ -39,6 +251,275 @@ test_authentication: True ''' +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "LDAP federation created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +ldap_federation: + description: the LDAP federation representation empty if the asked federation is deleted or does not exist. + returned: always + type: dict + contains: + id: + description: UUID that identifies the LDAP federation + type: str + returned: on success + sample: de455375-6900-46a0-8d11-51554e1c3f18 + name: + description: the name of the LDAP federation + type: str + returned: on success + sample: my-company-ldap + providerId: + description: the id of the federation, always ldap for this module + type: str + returned: on success + sample: ldap + providerType: + description: the type of the federation, always org.keycloak.storage.UserStorageProvider + type: str + returned: on success + sample: org.keycloak.storage.UserStorageProvider + parentId: + description: the parent of the federation + type: str + returned: on success + sample: master + config: + description: the configuration of the LDAP federation + type: dict: + returned: always + contains: + pagination: + description: whether the LDAP server supports pagination + type: bool + returned: on success + sample: true + fullSyncPeriod: + description: whether to periodic synchronize the Keycloak and the LDAP + type: int + returned: on success + sample: -1 + usersDn: + description: Full DN of LDAP tree where users are + type: str + returned: always + sample: ou=People, dc=MyCompany + connectionPooling: + description: whether to use connection pooling for accessing the LDAP server + type: bool + returned: on success + sample: true + cachePolicy: + description: cache policy for storage provider + type: str + returned: on success + sample: DEFAULT + useKerberosForPasswordAuthentication: + description: whether to use Kerberos for password authentication + type: bool + returned: on success + sample: false + importEnabled: + description: whether to save the LDAP users in the Keycloak database + type: bool + returned: on success + sample: false + enabled: + description: whether to enable the LDAP federation + type: bool + returned: always + sample: false + bindCredential: + description: the admin password + type: str + returned: on success + sample: admin_password + changedSyncPeriod: + description: whether periodic synchronization of new or changed users should be enable + type: int + returned: on success + sample: -1 + bindDn: + description: DN of LDAP admin + type: str + returned: on success + sample: cn=admindc=Metrondc=io + usernameLDAPAttribute: + description: Name of the LDAP attribute to map to the Keycloak username + type: str + returned: on success + sample: uuid + vendor: + description: LDAP provider + type: str + returned: on success + sample: other + uuidLDAPAttribute: + description: Name of LDAP attribute which is used as unique object identifier for object in LDAP + type: str + returned: on success + sample: entryUUID + allowKerberosAuthentication: + description: whether to allow Kerberos authentication + type: bool + returned: on success + sample: false + connectionUrl: + description: the url of the LDAP service + type: str + returned: on success + sample: ldap://openldap + syncRegistrations: + description: Whether new user created in the Keycloak should be created within the LDAP + type: bool + returned: on success + sample: true + authType: + description: LDAP authentication type (simple or none) + type: str + returned: on success + sample: simple + debug: + description: whether the debug mode is activated + type: bool + returned: on success + sample: false + searchScope: + description: Set how users are search, on one level (1) or in all the subtree (2) + type: int + returned: on success + sample: 1 + useTruststoreSpi: + description: Whether LDAP connection will use the trustore SPI with the trustore conifgure in the standalone.xml + type: str + returned: on success + sample: ldapsOnly + priority: + description: priority of the provider when doing an user lookup (lowest first) + type: int + returned: on success + sample: 0 + userObjectClasses: + description: LDAP object class attributes for users + type: str + returned: on success + sample: inetOrgPerson, organizationalPerson + rdnLDAPAttribute: + description: Name of the LDAP attribute to use as top attribute + type: str + returned: on success + sample: entryUUID + editMode: + description: The behaviour of the Keycloak with the LDAP. + type: str + returned: on success + sample: READ_ONLY + validatePasswordPolicy: + description: whether Keycloak should validate the password with the realm password policy before updating it + type: bool + returned: on success + sample: false + batchSizeForSync: + description: Count of LDAP users to be imported from the LDAP to Keycoak within single transaction + type: int + returned: on success + sample: 1000 + evictionDay: + description: Day of the week the entry will become invalid on (1 is Sunday) + type: int + returned: on success + sample: 1 + evictionHour: + description: Hour of the week the entry will become invalid on + type: int + returned: on success + sample: 2 + evictionMinute: + description: Minute of the week the entry will become invalid on + type: int + returned: on success + sample: 20 + maxLifespan: + description: Max lifespan of cache entry in millisecond + type: int + returned: on success + sample: 1000 + customUserSearchFilter: + description: Filter for searching user in the LDAP + type: str + returned: on success + sample: + connectionPoolingAuthentication: + description: Authentication type that may be pooled + type: str + returned: on success + sample: simple + connectionPoolingDebug: + description: The level of debug output to produce + type: int + returned: on success + sample: fine + connectionPoolingInitSize: + description: the number of connection per connection indentity to create when initialy createing a connection for the identity + type: str + returned: on success + sample: "2" + connectionPoolingMaxSize: + description: the maximum number of connection per connection indentity that can be maintained concurrently + type: str + returned: on success + sample: 1000 + connectionPoolingPrefSize: + description: the maximum number of connection per connection indentity that should be maintained concurrently + type: str + returned: on success + sample: 5 + connectionPoolingProtocol: + description: Protocol types of connection that may be pooled (plain or ssl) + type: str + returned: on success + sample: plain ssl + connectionPoolingTimeout: + description: the number of milliseconds that an idle connection may remain in the pool without being closed and removed from the pool + type: str + returned: on success + sample: 1000 + connectionTimeout: + description: LDAP connection timeout in milliseconds + type: str + returned: on success + sample: 1000 + readTimeout: + description: LDAP timeout in milliseconds for read operations + type: str + returned: on success + sample: 1000 + serverPrincipal: + description: Full name of Kerberos server principal for hhtp service including serv and domain name. + type: str + returned: on success + sample: HTTP/host.foo.org@FOO.ORG + keyTab: + description: Location of Kerberos keytab file containing the credentials of server principal + type: str + returned: on success + sample: /etc/krb5.keytab + kerberosRealm: + description: Name of Kerberos realm + type: str + returned: on success + sample: FOO.ORG +''' + import json from copy import deepcopy from ansible.module_utils._text import to_text From 1d673cb566c2d675deaa730fe9c6f1c922221734 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 16:42:29 +0200 Subject: [PATCH 32/79] dev: change type of user_object_classes from str to list --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 5 ++++- .../identity/keycloak/test_keycloak_ldap_federation.py | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index d7d1bef83bf7c5..db74cb3115a750 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -826,6 +826,9 @@ def _create_payload(self): else: if key == 'search_scope': config.update({camel(key): [SEARCH_SCOPE[value]]}) + elif key == 'user_object_classes': + value.sort() + config.update({camel(key): [', '.join(value)]}) else: config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) try: @@ -936,7 +939,7 @@ def run_module(): type='str', aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], ), - user_object_classes=dict(type='str', aliases=['userObjectClasses']), + user_object_classes=dict(type='list', elements='str', aliases=['userObjectClasses']), connection_url=dict(type='str', aliases=['connectionUrl']), users_dn=dict(type='str', aliases=['usersDn']), bind_dn=dict(type='str', aliases=['bindDn']), diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index e541b0aded572f..64ca3d8f08ee8c 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -244,7 +244,7 @@ def test_state_present_should_create_absent_federation( 'synchronize_registrations': True, 'username_ldap_attribute': 'cn', 'rdn_ldap_attribute': 'cn', - 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'user_object_classes': ['inetOrgPerson', 'organizationalPerson'], 'connection_url': 'ldap://openldap', 'users_dn': 'ou=People,dc=my-company', 'bind_dn': 'cn=admin,dc=my-company', @@ -359,7 +359,7 @@ def test_arguments_check_connectivity_should_try_ldap_connection( 'synchronize_registrations': True, 'username_ldap_attribute': 'cn', 'rdn_ldap_attribute': 'cn', - 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'user_object_classes': ['inetOrgPerson', 'organizationalPerson'], 'connection_url': 'ldap://openldap', 'users_dn': 'ou=People,dc=my-company', 'bind_dn': 'cn=admin,dc=my-company', @@ -458,7 +458,7 @@ def test_wrong_ldap_credentials_should_raise_an_error( 'state': 'present', 'federation_id': 'company-ldap', 'rdn_ldap_attribute': 'cn', - 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'user_object_classes': ['inetOrgPerson', 'organizationalPerson'], 'username_ldap_attribute': 'cn', 'users_dn': 'ou=People,dc=my-company', 'uuid_ldap_attribute': 'entryUUID', From 36fa5df946382a0199cadb3f40b24fb4414d5382 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 17:01:51 +0200 Subject: [PATCH 33/79] test: check that a connection check during update use existing parameters --- .../keycloak/test_keycloak_ldap_federation.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 64ca3d8f08ee8c..74026954c48963 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -483,12 +483,16 @@ def mock_update_url(mocker): 'id': '123-123', 'name': 'company-ldap', 'parentId': 'master', - 'config': {'pagination': True}, + 'config': { + 'pagination': [True], + 'bindDn': ['cn:admin'], + }, } ] ) ), 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, } return mocker.patch( 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', @@ -530,3 +534,30 @@ def test_state_present_should_update_existing_federation( } diff_result = recursive_diff(ansible_exit_json['ldap_federation'], reference_result) assert not diff_result + + +def test_state_present_should_update_existing_federation_with_connect_check( + monkeypatch, mock_get_token, mock_update_url +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + 'uuid_ldap_attribute': 'newEntryUUID', + 'bind_credential': 'new_admin_password', + 'test_authentication': True, + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson): + keycloak_ldap_federation.run_module() + calls = mock_update_url.mock_calls + for one_call in filterfalse(lambda x: 'testLDAPConnection' not in x.args[0], calls): + send_data = one_call.kwargs['data'] + assert urlencode({'bindCredential': 'new_admin_password'}) in send_data + assert urlencode({'bindDn': 'cn:admin'}) in send_data From e208b40e5d040009a26ed5e1e925bf21a8da37e9 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 17:18:55 +0200 Subject: [PATCH 34/79] test: put value in list for federation given in mock --- .../identity/keycloak/test_keycloak_ldap_federation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 74026954c48963..8c4b2dbdb20dde 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -155,7 +155,7 @@ def mock_delete_url(mocker): 'id': '123-123', 'name': 'ldap-to-delete', 'parentId': 'master', - 'config': {'pagination': True}, + 'config': {'pagination': [True]}, } ] ) @@ -168,7 +168,7 @@ def mock_delete_url(mocker): 'id': '123-123', 'name': 'ldap-to-delete', 'parentId': 'master', - 'config': {'pagination': True}, + 'config': {'pagination': [True]}, } ) ), From 4ab34639a404226c1af9884a7ca484c0e187452e Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 17:40:05 +0200 Subject: [PATCH 35/79] dev: get the existing value if the ansible parameter value is not given --- .../keycloak/keycloak_ldap_federation.py | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index db74cb3115a750..8e435787075788 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -546,10 +546,13 @@ class LdapFederation(object): """Keycloak LDAP Federation class. """ + def __init__(self, module, connection_header): self.module = module self.restheaders = connection_header - self.federation = self.get_federation() + self.federation = self._clean_payload( + self.get_federation(), credential_clean=False + ) try: self.uuid = self.federation['id'] except KeyError: @@ -756,14 +759,29 @@ def _call_test_url(self, extra_arguments): :return: a boolean showing if the connection or the authentication works. """ - payload = { - 'bindCredential': self.module.params.get('bind_credential', ''), - 'bindDn': self.module.params.get('bind_dn', ''), - 'connectionUrl': self.module.params.get('connection_url'), - 'connectionTimeout': '', - 'realm': self.module.params.get('realm'), - 'useTruststoreSpi': self.module.params.get('useTruststoreSpi', 'ldapsOnly'), - } + try: + trust_store = self.federation['config']['useTruststoreSpi'] + except KeyError: + trust_store = 'ldapsOnly' + federation_keys = [ + 'bind_credential', + 'bind_dn', + 'connection_url', + 'realm', + 'use_truststore_spi', + ] + payload = {'connectionTimeout': ''} + for one_key in federation_keys: + value = self.module.params[one_key] + if value: + payload.update({camel(one_key): value}) + else: + if one_key == 'use_truststore_spi': + payload.update({camel(one_key): trust_store}) + else: + payload.update( + {camel(one_key): self.federation['config'].get(camel(one_key), '')} + ) payload.update(extra_arguments) test_url = TEST_LDAP_CONNECTION.format( url=self.module.params.get('auth_keycloak_url'), @@ -849,21 +867,24 @@ def get_result(self): return self._clean_payload(self._create_payload()) @staticmethod - def _clean_payload(payload): + def _clean_payload(payload, credential_clean=True): """Clean the payload from credentials and extra list. :param payload: the payload given to the post or put request. :return: the cleaned payload :rtype: dict """ + if not payload: + return {} clean_payload = deepcopy(payload) old_config = clean_payload.pop('config') new_config = {} for key, value in old_config.items(): - if key != 'bindCredential': - new_config.update({key: value[0]}) - else: + if key == 'bindCredential' and credential_clean: new_config.update({key: 'no_log'}) + else: + new_config.update({key: value[0]}) + clean_payload.update({'config': new_config}) return clean_payload @@ -912,7 +933,9 @@ def run_module(): federation_uuid=dict(type='str', aliases=['federationUuid']), enable=dict(type='bool'), pagination=dict(type='bool'), - vendor=dict(type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory']), + vendor=dict( + type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory'] + ), edit_mode=dict( type='str', choices=['READ_ONLY', 'UNSYNCED', 'WRITABLE'], @@ -939,7 +962,9 @@ def run_module(): type='str', aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], ), - user_object_classes=dict(type='list', elements='str', aliases=['userObjectClasses']), + user_object_classes=dict( + type='list', elements='str', aliases=['userObjectClasses'] + ), connection_url=dict(type='str', aliases=['connectionUrl']), users_dn=dict(type='str', aliases=['usersDn']), bind_dn=dict(type='str', aliases=['bindDn']), From c48cd377e610d5951ce7279c5709c26b8ca7c502 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Tue, 18 Jun 2019 16:14:38 +0200 Subject: [PATCH 36/79] test: check that credentials value is no_log --- test/units/modules/identity/keycloak/test_keycloak_user.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/units/modules/identity/keycloak/test_keycloak_user.py b/test/units/modules/identity/keycloak/test_keycloak_user.py index 09fda1b45629b0..690c21c7279238 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_user.py +++ b/test/units/modules/identity/keycloak/test_keycloak_user.py @@ -339,6 +339,7 @@ def test_state_present_should_update_existing_user(monkeypatch, dynamic_url_for_ ansible_exit_json = exec_error.value.args[0] assert ansible_exit_json['msg'] == ('User %s has been updated.' % list(user_to_update.values())[0].lower()) assert ansible_exit_json['end_state'] == json.loads(UPDATED_USER) + assert ansible_exit_json['proposed']['credentials']['value'] == 'no_log' @pytest.mark.parametrize('wrong_attributes', [ From f2207ccb56cdfbe67b0a563a63b297c341b1672e Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Tue, 18 Jun 2019 16:15:11 +0200 Subject: [PATCH 37/79] dev: add 'value' in credential key to set at no_log --- lib/ansible/modules/identity/keycloak/keycloak_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index b5e7e3acda4d8c..201658559a0bbd 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -219,7 +219,7 @@ def sanitize_user_representation(user_representation): result = user_representation.copy() if 'credentials' in result: # check if this value are to sanitize - for credential_key in ['hashedSaltedValue', 'salt']: + for credential_key in ['hashedSaltedValue', 'salt', 'value']: if credential_key in result['credentials']: result['credentials'][credential_key] = 'no_log' return result @@ -357,7 +357,7 @@ def create_changeset(module): pass changeset[camel(user_param)] = new_param_value - return changeset + return sanitize_user_representation(changeset) def do_nothing_and_exit(kc, result): From ad3c7636e540bb7105193c95e5ff05d05aef92d5 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Fri, 14 Jun 2019 10:40:06 +0200 Subject: [PATCH 38/79] add keycloak_module for linking user and group --- .../identity/keycloak/keycloak.py | 75 ++- .../keycloak/keycloak_link_user_to_group.py | 364 +++++++++++++ .../test_keycloak_link_user_to_group.py | 493 ++++++++++++++++++ 3 files changed, 929 insertions(+), 3 deletions(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_link_user_to_group.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index d2f8c795f2e237..7f7a85fdc0efbb 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -74,6 +74,78 @@ URL_REALMS = "{url}/admin/realms" +def get_on_url(url, restheaders, module, description): + """Get a keycloak url + + :param url: the url to get + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the get object description put in the error message + if the open_url fails + :return: the read json from the url + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + try: + return json.load(open_url(url, method='GET', + headers=restheaders.header, + validate_certs=validate_certs)) + except ValueError as e: + module.fail_json( + msg='API returned incorrect JSON when trying to obtain %s for realm %s: %s' + % (description, realm, str(e))) + except Exception as e: + module.fail_json( + msg='Could not obtain %s for realm %s: %s' % (description, realm, str(e))) + + +def put_on_url(url, restheaders, module, description, representation=None): + """Put on a keycloak url + + :param url: the url to put + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the put object description put in the error message + if the open_url fails + :param representation: the object as a dictionary to put on keycloak + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + if not representation: + pushed_data = json.dumps(representation) + else: + pushed_data = {} + try: + return open_url(url, method='PUT', + headers=restheaders.header, + data=pushed_data, + validate_certs=validate_certs) + except Exception as e: + module.fail_json( + msg="Could not modified %s in realm %s: %s" % (description, realm, str(e))) + + +def delete_on_url(url, restheaders, module, description): + """Delete a keycloak url + + :param url: the url to delete + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the deleted object description put in the error message + if the open_url fails + :return: the read json from the url + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + try: + return open_url(url, method='DELETE', + headers=restheaders.header, + validate_certs=validate_certs) + except Exception as e: + module.fail_json( + msg="Could not delete %s in realm %s: %s" % (description, realm, str(e))) + + def keycloak_argument_spec(): """ Returns argument_spec of options common to keycloak_*-modules @@ -232,7 +304,6 @@ def get_client_by_id(self, id, realm='master'): try: return json.load(open_url(client_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) - except HTTPError as e: if e.code == 404: return None @@ -648,7 +719,6 @@ def get_users(self, realm='master', filter=None): userlist_url = URL_USERS.format(url=self.baseurl, realm=realm) if filter is not None: userlist_url += '?userId=%s' % filter - try: user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) @@ -668,7 +738,6 @@ def get_user_by_id(self, id, realm='master'): :return: dict of user representation or None if none matching exists """ url = URL_USER.format(url=self.baseurl, id=id, realm=realm) - try: return json.load( open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py new file mode 100644 index 00000000000000..b118c002293a44 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -0,0 +1,364 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = r''' +--- +module: keycloak_link_user_to_group + +short_description: Allows administration of Keycloak mapping between group and users via Keycloak API + +version_added: "2.9" + +description: + - This module allows the administration of link between user and group in Keycloak 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/4.8/rest-api/index.html/). + Aliases are provided so camelCased versions can be used as well. If they are in conflict + with ansible names or previous used names, they will be prefixed by "keycloak". + - The group and users should exist before the call to this module. If not, + a error message will be return. + +options: + state: + description: + - State of the mapping + - On C(present), the mapping between user and group will be created if it not exists. + - On C(absent), the mapping between user and group will be removed if it exists + type: str + choices: [ present, absent ] + default: present + + realm: + description: + - The realm where the role, group and optionaly client are. + type: str + default: master + + group_name: + description: + - Name of the group + - This parameter is mutually exclusive with group_id and one of + them is required by the module. + aliases: [ groupName ] + type: str + + group_id: + description: + - Id (as a uuid) of the group + - This parameter is mutually exclusive with group_name and one of + them is required by the module. + aliases: [ groupId ] + type: str + + user_id: + description: + - user_id of client to be worked on. This is usually an UUID. This and I(client_username) + are mutually exclusive. + aliases: [ userId ] + type: str + + keycloak_username: + description: + - username of user to be worked on. This and I(user_id) are mutually exclusive. + aliases: [ keycloakUsername ] + type: str + +extends_documentation_fragment: + - keycloak +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = r''' +- name: create the mapping if it does not exist + keycloak_link_user_to_group: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + group_name: one_group + keycloak_username: one_role +- name delete the mapping if it exists + keycloak_link_user_to_group: + auth_client_id: admin-cli + auth_keycloak_url: http://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: absent + group_id: bc8e2fc4-741a-47f1-b342-1996eb534404 + user_id: 6970217a-7977-40e8-a96e-49fe57430a4c +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Link between one_user and one_group created." +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool +link_user_to_group: + description: the given identifier for the group and the user linked or an empty dictionary if the link does not exist at the end of the module. + returned: always + type: dict + sample: { + 'group_name': 'group1', + 'keycloak_username': 'user1' + } +''' + +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakAPI, + keycloak_argument_spec, + KeycloakAuthorizationHeader, + get_on_url, + put_on_url, + delete_on_url, +) +from ansible.module_utils.basic import AnsibleModule + + +LIST_GROUP_OF_USER_URL = '{url}/admin/realms/{realm}/users/{id}/groups' +LINK_MODIFICATION_URL = '{url}/admin/realms/{realm}/users/{user_id}/groups/{group_id}' + + +def get_all_mutually_exclusive_values(module): + exclusive_arguments = {} + for one_mutually_exclusive in module.mutually_exclusive: + for one_argument in one_mutually_exclusive: + value = module.params.get(one_argument) + if value: + exclusive_arguments.update({one_argument: value}) + break + return exclusive_arguments + + +class KeycloakLinkUserToGroup(object): + def __init__(self, module, connection_header): + self.module = module + self.restheader = connection_header + exclusive_arguments = get_all_mutually_exclusive_values(module) + old_module = KeycloakAPI(module, self.restheader) + self._group_id, self.given_group = self._get_group_id( + module, exclusive_arguments, old_module + ) + self._user_id, self.given_user = self._get_user_id( + module, exclusive_arguments, old_module + ) + self._user_groups_id = [] + self._list_group_for_users() + + @staticmethod + def _get_group_id(module, exclusive_arguments, old_module): + realm = module.params.get('realm') + if 'group_name' in list(exclusive_arguments.keys()): + value = exclusive_arguments['group_name'] + group = old_module.get_group_by_name(value, realm) + else: + value = exclusive_arguments['group_id'] + group = old_module.get_group_by_groupid(value, realm) + try: + return group['id'], value + except TypeError: + module.fail_json(msg='Group %s does not exist' % value) + + @staticmethod + def _get_user_id(module, exclusive_arguments, old_module): + realm = module.params.get('realm') + if 'keycloak_username' in list(exclusive_arguments.keys()): + value = exclusive_arguments['keycloak_username'] + user = old_module.get_user_by_name(value, realm) + else: + value = exclusive_arguments['user_id'] + user = old_module.get_user_by_id(value, realm) + try: + return user['id'], value + except TypeError: + module.fail_json(msg='User %s does not exist' % value) + + def _list_group_for_users(self): + url = LIST_GROUP_OF_USER_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=self.module.params.get('realm'), + id=self._user_id, + ) + group_response = get_on_url( + url=url, + restheaders=self.restheader, + module=self.module, + description='groups of user', + ) + print(group_response) + self._user_groups_id = [one_group['id'] for one_group in group_response] + + def are_user_and_group_linked(self): + if self._group_id in self._user_groups_id: + return True + return False + + def create_link(self): + url = LINK_MODIFICATION_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=self.module.params.get('realm'), + user_id=self._user_id, + group_id=self._group_id, + ) + description = 'link between {user_name} and {group_name}'.format( + user_name=self.module.params.get('keycloak_username'), + group_name=self.module.params.get('group_name'), + ) + put_on_url( + url=url, + restheaders=self.restheader, + module=self.module, + description=description, + ) + + def delete_link(self): + url = LINK_MODIFICATION_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=self.module.params.get('realm'), + user_id=self._user_id, + group_id=self._group_id, + ) + description = 'link between {user_name} and {group_name}'.format( + user_name=self.module.params.get('keycloak_username'), + group_name=self.module.params.get('group_name'), + ) + delete_on_url( + url=url, + restheaders=self.restheader, + module=self.module, + description=description, + ) + + def get_link_representation(self): + link_representation = {} + exclusive_arguments = get_all_mutually_exclusive_values(self.module) + if 'group_name' in list(exclusive_arguments.keys()): + link_representation.update( + {'group_name': exclusive_arguments['group_name']} + ) + else: + link_representation.update({'group_id': exclusive_arguments['group_id']}) + if 'keycloak_username' in list(exclusive_arguments.keys()): + link_representation.update( + {'keycloak_username': exclusive_arguments['keycloak_username']} + ) + else: + link_representation.update({'user_id': exclusive_arguments['user_id']}) + return link_representation + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + keycloak_username=dict(type='str', aliases=['keycloakUsername']), + user_id=dict(type='str', aliases=['userId']), + group_name=dict(type='str', aliases=['groupName']), + group_id=dict(type='str', aliases=['groupId']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['keycloak_username', 'user_id'], ['group_name', 'group_id']], + mutually_exclusive=[ + ['keycloak_username', 'user_id'], + ['group_name', 'group_id'], + ], + ) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + + link_user_to_group = KeycloakLinkUserToGroup(module, connection_header) + state = module.params.get('state') + result = {} + + if not link_user_to_group.are_user_and_group_linked() and state == 'absent': + result['msg'] = ( + 'Link between user {given_user_id} and group {given_group_id} does not exist, nothing to do.' + ).format( + given_group_id=link_user_to_group.given_group, + given_user_id=link_user_to_group.given_user, + ) + result['changed'] = False + result['link_user_to_group'] = {} + elif link_user_to_group.are_user_and_group_linked() and state == 'present': + result['msg'] = ( + 'Link between user {given_user_id} and group {given_group_id} exists, nothing to do.' + ).format( + given_group_id=link_user_to_group.given_group, + given_user_id=link_user_to_group.given_user, + ) + result['changed'] = False + result['link_user_to_group'] = link_user_to_group.get_link_representation() + elif not link_user_to_group.are_user_and_group_linked() and state == 'present': + if not module.check_mode: + result['changed'] = True + link_user_to_group.create_link() + else: + result['changed'] = False + + result['link_user_to_group'] = link_user_to_group.get_link_representation() + result['msg'] = ( + 'Link between user {given_user_id} and group {given_group_id} created.' + ).format( + given_group_id=link_user_to_group.given_group, + given_user_id=link_user_to_group.given_user, + ) + elif link_user_to_group.are_user_and_group_linked() and state == 'absent': + if not module.check_mode: + result['changed'] = True + link_user_to_group.delete_link() + else: + result['changed'] = False + result['link_user_to_group'] = {} + result['msg'] = ( + 'Link between user {given_user_id} and group {given_group_id} deleted.' + ).format( + given_group_id=link_user_to_group.given_group, + given_user_id=link_user_to_group.given_user, + ) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_link_user_to_group.py b/test/units/modules/identity/keycloak/test_keycloak_link_user_to_group.py new file mode 100644 index 00000000000000..fab8bb175965cb --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_link_user_to_group.py @@ -0,0 +1,493 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +from copy import deepcopy + +import pytest +from itertools import count +import json + +from ansible.module_utils.six import StringIO +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) +from ansible.modules.identity.keycloak import keycloak_link_user_to_group +from ansible.module_utils.six.moves.urllib.error import HTTPError + + +def raise_404(url): + def _raise_404(): + raise HTTPError( + url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('') + ) + + return _raise_404 + + +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 build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + try: + url = args[0] + except IndexError: + url = kwargs['url'] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + + return _mocked_requests + + +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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count + ) + return object_with_future_response + + +def get_given_user_and_group(arguments): + try: + given_group = arguments['group_name'] + except KeyError: + given_group = arguments['group_id'] + try: + given_user = arguments['keycloak_username'] + except KeyError: + given_user = arguments['user_id'] + return given_group, given_user + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +@pytest.fixture +def mock_state_absent_no_link_to_do(mocker): + response_dict = deepcopy(CONNECTION_DICT) + response_dict.update( + { + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps( + [ + {'id': '457-123', 'name': 'group1'}, + {'id': '123-321', 'name': 'not_asked_group'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/457-123': create_wrapper( + (json.dumps({'id': '457-123', 'name': 'group1'})) + ), + 'http://keycloak.url/auth/admin/realms/master/users': create_wrapper( + json.dumps( + [ + {'id': '345-543', 'username': 'user1'}, + {'id': '890-098', 'username': 'not_asked_user'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543': create_wrapper( + json.dumps({'id': '345-543', 'username': 'user1'}) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups': create_wrapper( + json.dumps([]) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dict), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'group_name': 'group1', 'keycloak_username': 'user1'}, + {'group_id': '457-123', 'user_id': '345-543'}, + ], + ids=['names', 'ids'], +) +def test_state_absent_without_link_should_do_nothing( + monkeypatch, mock_state_absent_no_link_to_do, extra_arguments +): + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_link_user_to_group.main() + ansible_exit_json = exec_trace.value.args[0] + assert not ansible_exit_json['changed'] + given_group, given_user = get_given_user_and_group(arguments) + assert ansible_exit_json['msg'] == ( + 'Link between user {given_user} and group {given_group} does not exist, nothing to do.'.format( + given_user=given_user, given_group=given_group + ) + ) + assert ansible_exit_json['link_user_to_group'] == {} + + +@pytest.fixture +def mock_state_present_link_exists(mocker): + response_dict = deepcopy(CONNECTION_DICT) + response_dict.update( + { + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps( + [ + {'id': '457-123', 'name': 'group1'}, + {'id': '123-321', 'name': 'not_asked_group'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/457-123': create_wrapper( + (json.dumps({'id': '457-123', 'name': 'group1'})) + ), + 'http://keycloak.url/auth/admin/realms/master/users': create_wrapper( + json.dumps( + [ + {'id': '345-543', 'username': 'user1'}, + {'id': '890-098', 'username': 'not_asked_user'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups': create_wrapper( + json.dumps([{'id': '457-123', 'name': 'group1'}]) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543': create_wrapper( + json.dumps({'id': '345-543', 'username': 'user1'}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dict), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'group_name': 'group1', 'keycloak_username': 'user1'}, + {'group_id': '457-123', 'user_id': '345-543'}, + ], + ids=['names', 'ids'], +) +def test_state_present_with_link_should_do_nothing( + monkeypatch, mock_state_present_link_exists, extra_arguments +): + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + } + arguments.update(extra_arguments) + given_group, given_user = get_given_user_and_group(arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_link_user_to_group.main() + ansible_exit_json = exec_trace.value.args[0] + assert not ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == ( + 'Link between user {given_user} and group {given_group} exists, nothing to do.'.format( + given_group=given_group, given_user=given_user + ) + ) + assert ansible_exit_json['link_user_to_group'] == extra_arguments + + +@pytest.fixture +def mock_create_link(mocker): + response_dict = deepcopy(CONNECTION_DICT) + response_dict.update( + { + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps( + [ + {'id': '457-123', 'name': 'group1'}, + {'id': '123-321', 'name': 'not_asked_group'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/457-123': create_wrapper( + (json.dumps({'id': '457-123', 'name': 'group1'})) + ), + 'http://keycloak.url/auth/admin/realms/master/users': create_wrapper( + json.dumps( + [ + {'id': '345-543', 'username': 'user1'}, + {'id': '890-098', 'username': 'not_asked_user'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543': create_wrapper( + json.dumps({'id': '345-543', 'username': 'user1'}) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups/345-543': None, + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dict), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'group_name': 'group1', 'keycloak_username': 'user1'}, + {'group_id': '457-123', 'user_id': '345-543'}, + ], + ids=['names', 'ids'], +) +def test_state_present_should_create_non_existing_link( + monkeypatch, mock_create_link, extra_arguments +): + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + } + arguments.update(extra_arguments) + + set_module_args(arguments) + given_group, given_user = get_given_user_and_group(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_link_user_to_group.main() + ansible_exit_json = exec_trace.value.args[0] + assert ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == ( + 'Link between user {given_user} and group {given_group} created.'.format( + given_user=given_user, given_group=given_group + ) + ) + assert ansible_exit_json['link_user_to_group'] == extra_arguments + + +@pytest.fixture +def mock_delete_link(mocker): + response_dict = deepcopy(CONNECTION_DICT) + response_dict.update( + { + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps( + [ + {'id': '457-123', 'name': 'group1'}, + {'id': '123-321', 'name': 'not_asked_group'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/457-123': create_wrapper( + (json.dumps({'id': '457-123', 'name': 'group1'})) + ), + 'http://keycloak.url/auth/admin/realms/master/users': create_wrapper( + json.dumps( + [ + {'id': '345-543', 'username': 'user1'}, + {'id': '890-098', 'username': 'not_asked_user'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543': create_wrapper( + json.dumps({'id': '345-543', 'username': 'user1'}) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups': create_wrapper( + json.dumps([{'id': '457-123', 'name': 'group1'}]) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543/groups/345-543': None, + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dict), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'group_name': 'group1', 'keycloak_username': 'user1'}, + {'group_id': '457-123', 'user_id': '345-543'}, + ], + ids=['names', 'ids'], +) +def test_state_absent_should_delete_existing_link( + monkeypatch, mock_delete_link, extra_arguments +): + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + + set_module_args(arguments) + given_group, given_user = get_given_user_and_group(arguments) + with pytest.raises(AnsibleExitJson) as exec_trace: + keycloak_link_user_to_group.main() + ansible_exit_json = exec_trace.value.args[0] + assert ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == ( + 'Link between user {given_user} and group {given_group} deleted.'.format( + given_user=given_user, given_group=given_group + ) + ) + assert ansible_exit_json['link_user_to_group'] == {} + + +@pytest.fixture +def mock_does_not_exist(mocker): + response_dict = deepcopy(CONNECTION_DICT) + response_dict.update( + { + 'http://keycloak.url/auth/admin/realms/master/groups': create_wrapper( + json.dumps( + [ + {'id': '457-123', 'name': 'group1'}, + {'id': '123-321', 'name': 'not_asked_group'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/groups/457-123': create_wrapper( + (json.dumps({'id': '457-123', 'name': 'group1'})) + ), + 'http://keycloak.url/auth/admin/realms/master/users': create_wrapper( + json.dumps( + [ + {'id': '345-543', 'username': 'user1'}, + {'id': '890-098', 'username': 'not_asked_user'}, + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/users/345-543': create_wrapper( + json.dumps({'id': '345-543', 'username': 'user1'}) + ), + 'http://keycloak.url/auth/admin/realms/master/users/111-111': raise_404( + 'users/111-111' + ), + 'http://keycloak.url/auth/admin/realms/master/groups/222-222': raise_404( + 'groups/222-222' + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), response_dict), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'group_name': 'does_not_exist', 'keycloak_username': 'user1'}, + {'group_id': '222-222', 'user_id': '345-543'}, + {'group_name': 'group1', 'keycloak_username': 'does_not_exist'}, + {'group_id': '457-123', 'user_id': '111-111'}, + ], + ids=['group name', 'group id', 'user name', 'user id'], +) +def test_group_or_user_does_not_exist_should_fail( + monkeypatch, mock_does_not_exist, extra_arguments, request +): + test_id = request.node.nodeid.split('[')[1].split(']')[0] + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_link_user_to_group.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'absent', + } + arguments.update(extra_arguments) + set_module_args(arguments) + given_group, given_user = get_given_user_and_group(arguments) + with pytest.raises(AnsibleFailJson) as exec_trace: + keycloak_link_user_to_group.main() + ansible_exit_json = exec_trace.value.args[0] + if 'group' in test_id: + assert ansible_exit_json['msg'] == 'Group {} does not exist'.format(given_group) + else: + assert ansible_exit_json['msg'] == 'User {} does not exist'.format(given_user) From 938f5f50f3bdb14d5bab12ddfa4984aded0f34e0 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 18 Jun 2019 17:05:43 +0200 Subject: [PATCH 39/79] Delete double declared mock --- .../modules/identity/keycloak/test_group_role_mapping.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_group_role_mapping.py b/test/units/modules/identity/keycloak/test_group_role_mapping.py index 0d6f6485299441..f21504cc3ebe30 100644 --- a/test/units/modules/identity/keycloak/test_group_role_mapping.py +++ b/test/units/modules/identity/keycloak/test_group_role_mapping.py @@ -56,12 +56,6 @@ def get_response(object_with_future_response, method, get_id_call_count): return object_with_future_response -def raise_404(url): - def _raise_404(): - raise HTTPError(url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('')) - return _raise_404 - - CONNECTION_DICT = { 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( '{"access_token": "a long token"}'), From c7cfcc32a0da94ca3794a3c1aa911a01935317e5 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 11:29:00 +0200 Subject: [PATCH 40/79] doc: change key user_secret to value --- lib/ansible/modules/identity/keycloak/keycloak_user.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index 201658559a0bbd..6d1220c0949d0e 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -158,7 +158,7 @@ last_name: test required_actions: [ UPDATE_PROFILE, CONFIGURE_TOTP ] attributes: {'one key': 'one value', 'another key': 42} - credentials: {'type': 'password', 'user_secret': 'userTest1secret'} + credentials: {'type': 'password', 'value': 'userTest1secret'} ''' RETURN = ''' From b0022b5d9a5064d65bf7a30fcf583323c5a78739 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 11:33:10 +0200 Subject: [PATCH 41/79] doc: delete last role staying --- .../modules/identity/keycloak/keycloak_link_user_to_group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py index b118c002293a44..d7c6d9978c7c94 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -46,7 +46,7 @@ realm: description: - - The realm where the role, group and optionaly client are. + - The realm where the user and group and are. type: str default: master @@ -95,7 +95,7 @@ auth_password: PASSWORD realm: master group_name: one_group - keycloak_username: one_role + keycloak_username: one_user - name delete the mapping if it exists keycloak_link_user_to_group: auth_client_id: admin-cli From d037b0efdaaf24d358e856499f74f9ec840aaed2 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 11:33:21 +0200 Subject: [PATCH 42/79] dev: delete print --- .../modules/identity/keycloak/keycloak_link_user_to_group.py | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py index d7c6d9978c7c94..bcc0584b90166c 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -210,7 +210,6 @@ def _list_group_for_users(self): module=self.module, description='groups of user', ) - print(group_response) self._user_groups_id = [one_group['id'] for one_group in group_response] def are_user_and_group_linked(self): From ffaef52e05cec3a156b2272267c0fd503b4ffc1e Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 15:37:26 +0200 Subject: [PATCH 43/79] dev: use to_text for creating messages --- .../keycloak/keycloak_link_user_to_group.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py index bcc0584b90166c..f08cedf574b709 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -138,6 +138,7 @@ delete_on_url, ) from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_text LIST_GROUP_OF_USER_URL = '{url}/admin/realms/{realm}/users/{id}/groups' @@ -308,21 +309,21 @@ def run_module(): result = {} if not link_user_to_group.are_user_and_group_linked() and state == 'absent': - result['msg'] = ( + result['msg'] = to_text(( 'Link between user {given_user_id} and group {given_group_id} does not exist, nothing to do.' ).format( given_group_id=link_user_to_group.given_group, given_user_id=link_user_to_group.given_user, - ) + )) result['changed'] = False result['link_user_to_group'] = {} elif link_user_to_group.are_user_and_group_linked() and state == 'present': - result['msg'] = ( + result['msg'] = to_text(( 'Link between user {given_user_id} and group {given_group_id} exists, nothing to do.' ).format( given_group_id=link_user_to_group.given_group, given_user_id=link_user_to_group.given_user, - ) + )) result['changed'] = False result['link_user_to_group'] = link_user_to_group.get_link_representation() elif not link_user_to_group.are_user_and_group_linked() and state == 'present': @@ -333,12 +334,12 @@ def run_module(): result['changed'] = False result['link_user_to_group'] = link_user_to_group.get_link_representation() - result['msg'] = ( + result['msg'] = to_text(( 'Link between user {given_user_id} and group {given_group_id} created.' ).format( given_group_id=link_user_to_group.given_group, given_user_id=link_user_to_group.given_user, - ) + )) elif link_user_to_group.are_user_and_group_linked() and state == 'absent': if not module.check_mode: result['changed'] = True @@ -346,12 +347,12 @@ def run_module(): else: result['changed'] = False result['link_user_to_group'] = {} - result['msg'] = ( + result['msg'] = to_text(( 'Link between user {given_user_id} and group {given_group_id} deleted.' ).format( given_group_id=link_user_to_group.given_group, given_user_id=link_user_to_group.given_user, - ) + )) module.exit_json(**result) From 4cc22dc707fc9b199fbe350ac32377a158f17bd3 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 16:19:22 +0200 Subject: [PATCH 44/79] update documentation --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 8e435787075788..1a100040d747f0 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -234,10 +234,11 @@ auth_username: USERNAME auth_password: PASSWORD realm: master - name: my-company-ldap + federation_id: my-company-ldap + vendor: other state: present edit_mode: WRITABLE - synchronize_registrations: True, + synchronize_registrations: true username_ldap_attribute: cn rdn_ldap_attribute: cn user_object_classes: inetOrgPerson, organizationalPerson From c01fe0a112c7af30818b239ecb8971dcdfe2a204 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 17:24:57 +0200 Subject: [PATCH 45/79] dev: correct error message --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 1a100040d747f0..345f28cd0b9be0 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -684,7 +684,7 @@ def update(self): ) except Exception as e: self.module.fail_json( - msg='Could not create federation %s in realm %s: %s' + msg='Could not update federation %s in realm %s: %s' % (self.given_id, self.module.params.get('realm'), str(e)) ) return self._clean_payload(federation_payload) From 9d64b557a8dbe86c466b5e7ba803fa410868892e Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Mon, 24 Jun 2019 17:25:17 +0200 Subject: [PATCH 46/79] wip: trying to fix the not updating federation --- .../keycloak/keycloak_ldap_federation.py | 7 ++-- .../keycloak/test_keycloak_ldap_federation.py | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 345f28cd0b9be0..5d0238e725dfe0 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -829,6 +829,7 @@ def _create_payload(self): :rtype: dict """ translation = {'federation_id': 'name', 'federation_uuid': 'id'} + config_translation = {'synchronize_registrations': 'syncRegistrations'} config = {} payload = { 'providerId': 'ldap', @@ -843,13 +844,15 @@ def _create_payload(self): if key in list(translation.keys()): payload.update({translation[key]: value}) else: - if key == 'search_scope': + if key in config_translation: + config.update({config_translation[key]: [str(value).lower()]}) + elif key == 'search_scope': config.update({camel(key): [SEARCH_SCOPE[value]]}) elif key == 'user_object_classes': value.sort() config.update({camel(key): [', '.join(value)]}) else: - config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) + config.update({camel(key).replace('Ldap', 'LDAP'): [str(value)]}) try: config['priority'] except KeyError: diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 8c4b2dbdb20dde..a15f694abaf370 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -561,3 +561,35 @@ def test_state_present_should_update_existing_federation_with_connect_check( send_data = one_call.kwargs['data'] assert urlencode({'bindCredential': 'new_admin_password'}) in send_data assert urlencode({'bindDn': 'cn:admin'}) in send_data + + +def test_sync_does_not_work(monkeypatch): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_client_id': 'admin-cli', + 'auth_keycloak_url': 'http://localhost:8080/auth', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_password': 'admin', + 'realm': 'master', + 'federation_id': 'my-company-ldap', + 'state': 'present', + 'edit_mode': 'WRITABLE', + 'synchronize_registrations': True, + 'username_ldap_attribute': 'cn', + 'rdn_ldap_attribute': 'cn', + 'user_object_classes': 'inetOrgPerson, organizationalPerson', + 'connection_url': 'ldap://openldap', + 'users_dn': 'ou=People,dc=my-company', + 'bind_dn': 'cn=admin,dc=Metron,dc=io', + 'bind_credential': 'admin', + 'uuid_ldap_attribute': 'entryUUID', + 'search_scope': 'subtree', + 'use_truststore_spi': 'never', + 'test_authentication': 'True', + 'vendor': 'other', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson): + keycloak_ldap_federation.run_module() From 03284ca260d1f00656e5976f8f6f69ef2b1b8f92 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 13:43:51 +0200 Subject: [PATCH 47/79] test: add test for checking mandatory keys for synchronization --- .../keycloak/test_keycloak_ldap_federation.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index a15f694abaf370..820df70c77a96f 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -563,33 +563,31 @@ def test_state_present_should_update_existing_federation_with_connect_check( assert urlencode({'bindDn': 'cn:admin'}) in send_data -def test_sync_does_not_work(monkeypatch): +def test_create_payload_for_synchronization(monkeypatch, mock_get_token, mock_update_url): + """When updating the sync registration of a federation, the payload needs + to have some keys. If not, the response is 204 put the sync registration + parameter is not updated.""" monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) arguments = { 'auth_client_id': 'admin-cli', - 'auth_keycloak_url': 'http://localhost:8080/auth', + 'auth_keycloak_url': 'http://keycloak.url/auth', 'auth_realm': 'master', - 'auth_username': 'admin', - 'auth_password': 'admin', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', 'realm': 'master', - 'federation_id': 'my-company-ldap', + 'federation_id': 'company-ldap', 'state': 'present', - 'edit_mode': 'WRITABLE', 'synchronize_registrations': True, - 'username_ldap_attribute': 'cn', - 'rdn_ldap_attribute': 'cn', - 'user_object_classes': 'inetOrgPerson, organizationalPerson', - 'connection_url': 'ldap://openldap', - 'users_dn': 'ou=People,dc=my-company', - 'bind_dn': 'cn=admin,dc=Metron,dc=io', - 'bind_credential': 'admin', - 'uuid_ldap_attribute': 'entryUUID', - 'search_scope': 'subtree', - 'use_truststore_spi': 'never', - 'test_authentication': 'True', - 'vendor': 'other', } set_module_args(arguments) with pytest.raises(AnsibleExitJson): keycloak_ldap_federation.run_module() + put_call = mock_update_url.mock_calls[1] + pushed_data = json.loads(put_call[2]['data']) + config = pushed_data.pop('config') + all_config_keys = list(config.keys()) + mandatory_keys_for_sync = [ + 'batchSizeForSync', 'fullSyncPeriod', 'changedSyncPeriod', 'evictionDay', 'evictionHour', 'evictionMinute', 'maxLifespan', 'customUserSearchFilter'] + for one_key in mandatory_keys_for_sync: + assert one_key in all_config_keys From 1404e5e39fbae635627131eec398477937e05fda Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 13:44:26 +0200 Subject: [PATCH 48/79] dev: first straight solution --- .../keycloak/keycloak_ldap_federation.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 5d0238e725dfe0..5329e6a9293ba0 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -845,7 +845,7 @@ def _create_payload(self): payload.update({translation[key]: value}) else: if key in config_translation: - config.update({config_translation[key]: [str(value).lower()]}) + config.update({config_translation[key]: [value]}) elif key == 'search_scope': config.update({camel(key): [SEARCH_SCOPE[value]]}) elif key == 'user_object_classes': @@ -858,6 +858,16 @@ def _create_payload(self): except KeyError: config.update({'priority': [0]}) # yet I don't need connection pooling to True but this key is mandatory. + config.update({ + 'batchSizeForSync': [], # [1000], + 'fullSyncPeriod': [], # [-1], + 'changedSyncPeriod': [], # [-1], + 'evictionDay': [], + 'evictionHour': [], + 'evictionMinute': [], + 'maxLifespan': [], + 'customUserSearchFilter': [], + }) config.update({'connectionPooling': [False]}) payload.update({'config': config}) return payload @@ -887,7 +897,10 @@ def _clean_payload(payload, credential_clean=True): if key == 'bindCredential' and credential_clean: new_config.update({key: 'no_log'}) else: - new_config.update({key: value[0]}) + try: + new_config.update({key: value[0]}) + except IndexError: + new_config.update({key: None}) clean_payload.update({'config': new_config}) return clean_payload From d1fa055c188b2650d5d98d0d4de34c48240b6c05 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 14:19:53 +0200 Subject: [PATCH 49/79] test: check the value of syncRegistrations --- .../identity/keycloak/test_keycloak_ldap_federation.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 820df70c77a96f..d77adaee2bcfc7 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -486,6 +486,15 @@ def mock_update_url(mocker): 'config': { 'pagination': [True], 'bindDn': ['cn:admin'], + 'batchSizeForSync': [1000], + 'fullSyncPeriod': [-1], + 'changedSyncPeriod': [-1], + 'evictionDay': [], + 'evictionHour': [], + 'evictionMinute': [], + 'maxLifespan': [], + 'customUserSearchFilter': [], + 'syncRegistrations': [False] }, } ] @@ -591,3 +600,4 @@ def test_create_payload_for_synchronization(monkeypatch, mock_get_token, mock_up 'batchSizeForSync', 'fullSyncPeriod', 'changedSyncPeriod', 'evictionDay', 'evictionHour', 'evictionMinute', 'maxLifespan', 'customUserSearchFilter'] for one_key in mandatory_keys_for_sync: assert one_key in all_config_keys + assert config['syncRegistrations'] == [True] From 53b6f4d39adb111abd0b376d5c2b89385992636e Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 15:54:49 +0200 Subject: [PATCH 50/79] test: update waited values --- .../keycloak/test_keycloak_ldap_federation.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index d77adaee2bcfc7..3658321542a65d 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -269,7 +269,7 @@ def test_state_present_should_create_absent_federation( 'priority': 0, 'rdnLDAPAttribute': 'cn', 'searchScope': 2, - 'synchronizeRegistrations': True, + 'syncRegistrations': True, 'useTruststoreSpi': 'never', 'userObjectClasses': 'inetOrgPerson, organizationalPerson', 'usernameLDAPAttribute': 'cn', @@ -292,7 +292,7 @@ def test_state_present_should_create_absent_federation( send_config.update({key: [value]}) reference_result.update({'config': send_config}) create_call = mock_create_url.mock_calls[1] - send_json = json.loads(create_call.kwargs['data']) + send_json = json.loads(create_call[2]['data']) diff_result = recursive_diff(send_json, reference_result) assert not diff_result @@ -494,7 +494,8 @@ def mock_update_url(mocker): 'evictionMinute': [], 'maxLifespan': [], 'customUserSearchFilter': [], - 'syncRegistrations': [False] + 'syncRegistrations': [False], + 'priority': [3], }, } ] @@ -533,8 +534,19 @@ def test_state_present_should_update_existing_federation( assert ansible_exit_json['changed'] reference_result = { 'config': { + 'pagination': True, + 'bindDn': 'cn:admin', + 'batchSizeForSync': 1000, + 'fullSyncPeriod': -1, + 'changedSyncPeriod': -1, + 'evictionDay': None, + 'evictionHour': None, + 'evictionMinute': None, + 'maxLifespan': None, + 'customUserSearchFilter': None, + 'syncRegistrations': False, + 'priority': 3, 'uuidLDAPAttribute': 'newEntryUUID', - 'priority': 0, 'connectionPooling': False, }, 'name': 'company-ldap', @@ -601,3 +613,4 @@ def test_create_payload_for_synchronization(monkeypatch, mock_get_token, mock_up for one_key in mandatory_keys_for_sync: assert one_key in all_config_keys assert config['syncRegistrations'] == [True] + assert config['priority'] == [3] From ce477dca1747172e61b8f1709ad4d23f36bf69d5 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 15:55:14 +0200 Subject: [PATCH 51/79] dev: update values to push from existing values in the federation --- .../keycloak/keycloak_ldap_federation.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 5329e6a9293ba0..3a7dd2f5721921 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -529,6 +529,7 @@ keycloak_argument_spec, KeycloakAuthorizationHeader, ) +from ansible.module_utils.common.dict_transformations import dict_merge from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import quote, urlencode @@ -854,22 +855,20 @@ def _create_payload(self): else: config.update({camel(key).replace('Ldap', 'LDAP'): [str(value)]}) try: - config['priority'] + old_configuration = {key: [value] for key, value in self.federation['config'].items()} except KeyError: - config.update({'priority': [0]}) + old_configuration = {} + new_configuration = dict_merge(old_configuration, config) + try: + new_configuration['priority'] + except KeyError: + new_configuration.update({'priority': [0]}) # yet I don't need connection pooling to True but this key is mandatory. - config.update({ - 'batchSizeForSync': [], # [1000], - 'fullSyncPeriod': [], # [-1], - 'changedSyncPeriod': [], # [-1], - 'evictionDay': [], - 'evictionHour': [], - 'evictionMinute': [], - 'maxLifespan': [], - 'customUserSearchFilter': [], - }) - config.update({'connectionPooling': [False]}) - payload.update({'config': config}) + try: + new_configuration['connectionPooling'] + except KeyError: + new_configuration.update({'connectionPooling': [False]}) + payload.update({'config': new_configuration}) return payload def get_result(self): From 96c178fa22cdd5f935dc6481238215d2bd40a46a Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 16:16:18 +0200 Subject: [PATCH 52/79] dev: add new parameter --- .../keycloak/keycloak_ldap_federation.py | 109 ++++++++++++------ 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 3a7dd2f5721921..f13644ac7152a1 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -535,13 +535,11 @@ from ansible.module_utils.six.moves.urllib.parse import quote, urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError - USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' COMPONENTS_URL = '{url}/admin/realms/{realm}/components/' TEST_LDAP_CONNECTION = '{url}/admin/realms/{realm}/testLDAPConnection' - SEARCH_SCOPE = {'one level': 1, 'subtree': 2} @@ -576,7 +574,8 @@ def _get_federation_url(self): return USER_FEDERATION_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), - federation_id=quote(self.module.params.get('federation_id')), + federation_id=quote( + self.module.params.get('federation_id')), ) return USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), @@ -608,20 +607,20 @@ def get_federation(self): else: self.module.fail_json( msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) except ValueError as e: self.module.fail_json( msg=( - 'API returned incorrect JSON when trying to obtain user ' - 'federation %s for realm %s: %s' - ) - % (to_text(self.given_id), to_text(realm), to_text(e)) + 'API returned incorrect JSON when trying to obtain user ' + 'federation %s for realm %s: %s' + ) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) except Exception as e: self.module.fail_json( msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) else: if json_federation: @@ -655,7 +654,7 @@ def delete(self): except Exception as e: self.module.fail_json( msg='Could not delete federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) def update(self): @@ -686,7 +685,7 @@ def update(self): except Exception as e: self.module.fail_json( msg='Could not update federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return self._clean_payload(federation_payload) @@ -723,7 +722,7 @@ def create(self): except Exception as e: self.module.fail_json( msg='Could not create federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return self._clean_payload(federation_payload) @@ -732,7 +731,7 @@ def _test_connection(self): if not self._call_test_url({'action': 'testConnection'}): self.module.fail_json( msg='The url connection %s cannot be reached.' - % (self.module.params.get('connection_url')) + % (self.module.params.get('connection_url')) ) def _test_authentication(self): @@ -740,11 +739,11 @@ def _test_authentication(self): if not self._call_test_url({'action': 'testAuthentication'}): self.module.fail_json( msg='The user %s cannot logged in the ldap at %s, ' - 'you should check your credentials.' - % ( - self.module.params.get('bind_dn'), - self.module.params.get('connection_url'), - ) + 'you should check your credentials.' + % ( + self.module.params.get('bind_dn'), + self.module.params.get('connection_url'), + ) ) def _call_test_url(self, extra_arguments): @@ -782,7 +781,8 @@ def _call_test_url(self, extra_arguments): payload.update({camel(one_key): trust_store}) else: payload.update( - {camel(one_key): self.federation['config'].get(camel(one_key), '')} + {camel(one_key): self.federation['config'].get( + camel(one_key), '')} ) payload.update(extra_arguments) test_url = TEST_LDAP_CONNECTION.format( @@ -791,7 +791,8 @@ def _call_test_url(self, extra_arguments): ) headers = deepcopy(self.restheaders.header) headers.update( - {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} + { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} ) try: open_url( @@ -806,12 +807,13 @@ def _call_test_url(self, extra_arguments): return False self.module.fail_json( msg='Could not test connection %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(http_error)) + % (self.given_id, self.module.params.get('realm'), + str(http_error)) ) except Exception as e: self.module.fail_json( msg='Could not test connection %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return True @@ -853,9 +855,11 @@ def _create_payload(self): value.sort() config.update({camel(key): [', '.join(value)]}) else: - config.update({camel(key).replace('Ldap', 'LDAP'): [str(value)]}) + config.update( + {camel(key).replace('Ldap', 'LDAP'): [str(value)]}) try: - old_configuration = {key: [value] for key, value in self.federation['config'].items()} + old_configuration = {key: [value] for key, value in + self.federation['config'].items()} except KeyError: old_configuration = {} new_configuration = dict_merge(old_configuration, config) @@ -868,6 +872,12 @@ def _create_payload(self): new_configuration['connectionPooling'] except KeyError: new_configuration.update({'connectionPooling': [False]}) + # check the presence of following keys: + # - cachePolicy (default value: ['DEFAULT']) + # - evictionDay: [] + # - evictionHour: [] + # - evictionMinute: [] + # - max_lifespan: [] payload.update({'config': new_configuration}) return payload @@ -933,7 +943,8 @@ def check_mandatory_arguments(self, creation_payload): if len(missing_element) > 1: missing_element.sort() elements_for_message = ', '.join(missing_element[:-1]) - elements_for_message += ' and {} are missing'.format(missing_element[-1]) + elements_for_message += ' and {} are missing'.format( + missing_element[-1]) else: elements_for_message = missing_element[0] + 'is missing' elements_for_message += ' for the federation creation.' @@ -943,7 +954,8 @@ def check_mandatory_arguments(self, creation_payload): def run_module(): argument_spec = keycloak_argument_spec() meta_args = dict( - state=dict(type='str', default='present', choices=['present', 'absent']), + state=dict(type='str', default='present', + choices=['present', 'absent']), realm=dict(type='str', default='master'), federation_id=dict(type='str', aliases=['federerationId']), federation_uuid=dict(type='str', aliases=['federationUuid']), @@ -958,14 +970,6 @@ def run_module(): aliases=['editMode'], ), import_enable=dict(type='bool', aliases=['importEnable']), - synchronize_registrations=dict( - type='bool', - aliases=[ - 'sync_registrations', - 'synchronizeRegistrations', - 'syncRegistrations', - ], - ), username_ldap_attribute=dict( type='str', aliases=[ @@ -976,7 +980,8 @@ def run_module(): ), rdn_ldap_attribute=dict( type='str', - aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], + aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', + 'rdn_LDAP_attribute'], ), user_object_classes=dict( type='list', elements='str', aliases=['userObjectClasses'] @@ -984,7 +989,8 @@ def run_module(): connection_url=dict(type='str', aliases=['connectionUrl']), users_dn=dict(type='str', aliases=['usersDn']), bind_dn=dict(type='str', aliases=['bindDn']), - bind_credential=dict(type='str', aliases=['bindCredential'], no_log=True), + bind_credential=dict(type='str', aliases=['bindCredential'], + no_log=True), custom_user_ldap_filter=dict( type='str', aliases=[ @@ -997,10 +1003,12 @@ def run_module(): ), uuid_ldap_attribute=dict( type='str', - aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', 'uuid_LDAP_attribute'], + aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', + 'uuid_LDAP_attribute'], ), search_scope=dict( - type='str', choices=['one level', 'subtree'], aliases=['searchScope'] + type='str', choices=['one level', 'subtree'], + aliases=['searchScope'] ), use_truststore_spi=dict( type='str', @@ -1009,6 +1017,31 @@ def run_module(): ), test_connection=dict(type='bool', aliases=['testConnection']), test_authentication=dict(type='bool', aliases=['testAuthentication']), + synchronize_registrations=dict( + type='bool', + aliases=[ + 'sync_registrations', + 'synchronizeRegistrations', + 'syncRegistrations', + ], + ), + batch_size_for_synchronization=dict(type='int', + aliases=['batch_size_for_sync', + 'batchSizeForSynchronization', + 'batchSizeForSync'], + ), + full_synchronization_period=dict(type='int', + aliases=['full_sync_period', + 'fullSynchronizationPeriod', + 'fullSyncPeriod'], + ), + # in second -1, switch off to test + changed_synchronization_period=dict(type='int', + aliases=['changedSyncPeriod', + 'changedSynchronizationPeriod', + 'changed_sync_period'], + ), + # in second -1, switch off to test ) # option not taken into account: # cache_policy=dict(type=str, choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN'], aliases=['cachePolicy']) From ca09c0ccb6aab39a0ac4367b36d6fd9baf5953a3 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 16:19:39 +0200 Subject: [PATCH 53/79] style: reformat with black --- .../keycloak/keycloak_ldap_federation.py | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index f13644ac7152a1..29fb807b2444fb 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -574,8 +574,7 @@ def _get_federation_url(self): return USER_FEDERATION_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), - federation_id=quote( - self.module.params.get('federation_id')), + federation_id=quote(self.module.params.get('federation_id')), ) return USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), @@ -607,20 +606,20 @@ def get_federation(self): else: self.module.fail_json( msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) except ValueError as e: self.module.fail_json( msg=( - 'API returned incorrect JSON when trying to obtain user ' - 'federation %s for realm %s: %s' - ) - % (to_text(self.given_id), to_text(realm), to_text(e)) + 'API returned incorrect JSON when trying to obtain user ' + 'federation %s for realm %s: %s' + ) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) except Exception as e: self.module.fail_json( msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) + % (to_text(self.given_id), to_text(realm), to_text(e)) ) else: if json_federation: @@ -654,7 +653,7 @@ def delete(self): except Exception as e: self.module.fail_json( msg='Could not delete federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) def update(self): @@ -685,7 +684,7 @@ def update(self): except Exception as e: self.module.fail_json( msg='Could not update federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return self._clean_payload(federation_payload) @@ -722,7 +721,7 @@ def create(self): except Exception as e: self.module.fail_json( msg='Could not create federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return self._clean_payload(federation_payload) @@ -731,7 +730,7 @@ def _test_connection(self): if not self._call_test_url({'action': 'testConnection'}): self.module.fail_json( msg='The url connection %s cannot be reached.' - % (self.module.params.get('connection_url')) + % (self.module.params.get('connection_url')) ) def _test_authentication(self): @@ -739,11 +738,11 @@ def _test_authentication(self): if not self._call_test_url({'action': 'testAuthentication'}): self.module.fail_json( msg='The user %s cannot logged in the ldap at %s, ' - 'you should check your credentials.' - % ( - self.module.params.get('bind_dn'), - self.module.params.get('connection_url'), - ) + 'you should check your credentials.' + % ( + self.module.params.get('bind_dn'), + self.module.params.get('connection_url'), + ) ) def _call_test_url(self, extra_arguments): @@ -781,8 +780,11 @@ def _call_test_url(self, extra_arguments): payload.update({camel(one_key): trust_store}) else: payload.update( - {camel(one_key): self.federation['config'].get( - camel(one_key), '')} + { + camel(one_key): self.federation['config'].get( + camel(one_key), '' + ) + } ) payload.update(extra_arguments) test_url = TEST_LDAP_CONNECTION.format( @@ -791,8 +793,7 @@ def _call_test_url(self, extra_arguments): ) headers = deepcopy(self.restheaders.header) headers.update( - { - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} + {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} ) try: open_url( @@ -807,13 +808,12 @@ def _call_test_url(self, extra_arguments): return False self.module.fail_json( msg='Could not test connection %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), - str(http_error)) + % (self.given_id, self.module.params.get('realm'), str(http_error)) ) except Exception as e: self.module.fail_json( msg='Could not test connection %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) + % (self.given_id, self.module.params.get('realm'), str(e)) ) return True @@ -856,10 +856,12 @@ def _create_payload(self): config.update({camel(key): [', '.join(value)]}) else: config.update( - {camel(key).replace('Ldap', 'LDAP'): [str(value)]}) + {camel(key).replace('Ldap', 'LDAP'): [str(value)]} + ) try: - old_configuration = {key: [value] for key, value in - self.federation['config'].items()} + old_configuration = { + key: [value] for key, value in self.federation['config'].items() + } except KeyError: old_configuration = {} new_configuration = dict_merge(old_configuration, config) @@ -943,8 +945,7 @@ def check_mandatory_arguments(self, creation_payload): if len(missing_element) > 1: missing_element.sort() elements_for_message = ', '.join(missing_element[:-1]) - elements_for_message += ' and {} are missing'.format( - missing_element[-1]) + elements_for_message += ' and {} are missing'.format(missing_element[-1]) else: elements_for_message = missing_element[0] + 'is missing' elements_for_message += ' for the federation creation.' @@ -954,8 +955,7 @@ def check_mandatory_arguments(self, creation_payload): def run_module(): argument_spec = keycloak_argument_spec() meta_args = dict( - state=dict(type='str', default='present', - choices=['present', 'absent']), + state=dict(type='str', default='present', choices=['present', 'absent']), realm=dict(type='str', default='master'), federation_id=dict(type='str', aliases=['federerationId']), federation_uuid=dict(type='str', aliases=['federationUuid']), @@ -980,8 +980,7 @@ def run_module(): ), rdn_ldap_attribute=dict( type='str', - aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', - 'rdn_LDAP_attribute'], + aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], ), user_object_classes=dict( type='list', elements='str', aliases=['userObjectClasses'] @@ -989,8 +988,7 @@ def run_module(): connection_url=dict(type='str', aliases=['connectionUrl']), users_dn=dict(type='str', aliases=['usersDn']), bind_dn=dict(type='str', aliases=['bindDn']), - bind_credential=dict(type='str', aliases=['bindCredential'], - no_log=True), + bind_credential=dict(type='str', aliases=['bindCredential'], no_log=True), custom_user_ldap_filter=dict( type='str', aliases=[ @@ -1003,12 +1001,10 @@ def run_module(): ), uuid_ldap_attribute=dict( type='str', - aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', - 'uuid_LDAP_attribute'], + aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', 'uuid_LDAP_attribute'], ), search_scope=dict( - type='str', choices=['one level', 'subtree'], - aliases=['searchScope'] + type='str', choices=['one level', 'subtree'], aliases=['searchScope'] ), use_truststore_spi=dict( type='str', @@ -1025,22 +1021,27 @@ def run_module(): 'syncRegistrations', ], ), - batch_size_for_synchronization=dict(type='int', - aliases=['batch_size_for_sync', - 'batchSizeForSynchronization', - 'batchSizeForSync'], - ), - full_synchronization_period=dict(type='int', - aliases=['full_sync_period', - 'fullSynchronizationPeriod', - 'fullSyncPeriod'], - ), + batch_size_for_synchronization=dict( + type='int', + aliases=[ + 'batch_size_for_sync', + 'batchSizeForSynchronization', + 'batchSizeForSync', + ], + ), + full_synchronization_period=dict( + type='int', + aliases=['full_sync_period', 'fullSynchronizationPeriod', 'fullSyncPeriod'], + ), # in second -1, switch off to test - changed_synchronization_period=dict(type='int', - aliases=['changedSyncPeriod', - 'changedSynchronizationPeriod', - 'changed_sync_period'], - ), + changed_synchronization_period=dict( + type='int', + aliases=[ + 'changedSyncPeriod', + 'changedSynchronizationPeriod', + 'changed_sync_period', + ], + ), # in second -1, switch off to test ) # option not taken into account: From 0901d97990a430e63fa7930e02cc5242cdca5776 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 16:55:47 +0200 Subject: [PATCH 54/79] test: update waited values --- .../identity/keycloak/test_keycloak_ldap_federation.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 3658321542a65d..72a24801a07943 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -276,6 +276,11 @@ def test_state_present_should_create_absent_federation( 'usersDn': 'ou=People,dc=my-company', 'uuidLDAPAttribute': 'entryUUID', 'vendor': 'other', + 'cachePolicy': 'DEFAULT', + 'evictionDay': None, + 'evictionHour': None, + 'evictionMinute': None, + 'maxLifespan': None, }, 'name': 'company-ldap', 'providerId': 'ldap', @@ -288,6 +293,8 @@ def test_state_present_should_create_absent_federation( for key, value in config.items(): if key == 'bindCredential': send_config.update({'bindCredential': ['ldap_admin_password']}) + elif value is None: + send_config.update({key: []}) else: send_config.update({key: [value]}) reference_result.update({'config': send_config}) @@ -496,6 +503,7 @@ def mock_update_url(mocker): 'customUserSearchFilter': [], 'syncRegistrations': [False], 'priority': [3], + 'cachePolicy': ['DEFAULT'], }, } ] @@ -548,6 +556,7 @@ def test_state_present_should_update_existing_federation( 'priority': 3, 'uuidLDAPAttribute': 'newEntryUUID', 'connectionPooling': False, + 'cachePolicy': 'DEFAULT', }, 'name': 'company-ldap', 'providerId': 'ldap', From b46876361a356f58ab9551de07e1f79e3298ee82 Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 25 Jun 2019 16:58:26 +0200 Subject: [PATCH 55/79] dev: add synchronization parameters --- .../keycloak/keycloak_ldap_federation.py | 78 +++++++++++++------ 1 file changed, 55 insertions(+), 23 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 29fb807b2444fb..c6a0303b01b4d5 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -215,6 +215,26 @@ type: bool aliases: [ testAuthentication ] + batch_size_for_synchronization: + description: + - Count of LDAP users to be imported from LDAP to Keycloak within single transaction + type: int + aliases: [ batch_size_for_sync, batchSizeForSynchronization, batchSizeForSync ] + + full_synchronization_period: + description: + - Period for full synchronisation in seconds + - If this number is under 1, the automatic synchronization would be disable + type: int + aliases: [ full_sync_period, fullSynchronizationPeriod, fullSyncPeriod ] + + changed_user_synchronization_period: + description: + - Period for changed or newly created users synchronization in seconds + - If this number is under 1, the automatic synchronization would be disable + type: int + aliases: [ changedUserSyncPeriod, changedUserSynchronizationPeriod, changed_user_sync_period ] + notes: - The following parameters existing in the UI are not taken into account in this module, I(importUser), I(validatePasswordPolicy), I(connectionPooling) (and all associated parameters), I(connectionTimeout), I(readTimeout), I(allowKerberosAuthentication) (and all associated parameters), I(useKerberosForPasswordAuthentication), I(batchSize), I(periodicFullSync), I(periodicChangedUserSync) and I(cachePolicy). @@ -344,7 +364,7 @@ type: str returned: on success sample: admin_password - changedSyncPeriod: + changedUserSyncPeriod: description: whether periodic synchronization of new or changed users should be enable type: int returned: on success @@ -865,22 +885,36 @@ def _create_payload(self): except KeyError: old_configuration = {} new_configuration = dict_merge(old_configuration, config) - try: - new_configuration['priority'] - except KeyError: - new_configuration.update({'priority': [0]}) - # yet I don't need connection pooling to True but this key is mandatory. - try: - new_configuration['connectionPooling'] - except KeyError: - new_configuration.update({'connectionPooling': [False]}) - # check the presence of following keys: - # - cachePolicy (default value: ['DEFAULT']) - # - evictionDay: [] - # - evictionHour: [] - # - evictionMinute: [] - # - max_lifespan: [] - payload.update({'config': new_configuration}) + # Yet I don't need the following keys to have values but there are mandatory + # in the payload. + dict_of_default = { + 'priority': 0, + 'connectionPooling': False, + 'cachePolicy': 'DEFAULT', + 'evictionDay': None, + 'evictionHour': None, + 'evictionMinute': None, + 'maxLifespan': None, + } + payload.update( + { + 'config': self._if_absent_add_a_default_value( + new_configuration, dict_of_default + ) + } + ) + return payload + + @staticmethod + def _if_absent_add_a_default_value(payload, dict_of_default): + for key in dict_of_default: + try: + payload[key] + except KeyError: + if dict_of_default[key] is None: + payload.update({key: []}) + else: + payload.update({key: [dict_of_default[key]]}) return payload def get_result(self): @@ -1033,16 +1067,14 @@ def run_module(): type='int', aliases=['full_sync_period', 'fullSynchronizationPeriod', 'fullSyncPeriod'], ), - # in second -1, switch off to test - changed_synchronization_period=dict( + changed_user_synchronization_period=dict( type='int', aliases=[ - 'changedSyncPeriod', - 'changedSynchronizationPeriod', - 'changed_sync_period', + 'changedUserSyncPeriod', + 'changedUserSynchronizationPeriod', + 'changed_user_sync_period', ], ), - # in second -1, switch off to test ) # option not taken into account: # cache_policy=dict(type=str, choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN'], aliases=['cachePolicy']) From b19c00c176e0a5422c2a6edd5bac316a2a42703e Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Wed, 26 Jun 2019 10:38:20 +0200 Subject: [PATCH 56/79] dev: add parameters validate_password_policy, read_timeout, connection_timeout don't convert to string the values --- .../keycloak/keycloak_ldap_federation.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index c6a0303b01b4d5..fe5e3032774190 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -235,8 +235,30 @@ type: int aliases: [ changedUserSyncPeriod, changedUserSynchronizationPeriod, changed_user_sync_period ] + validate_password_policy: + description: + - whether Keycloak should validate the password with the realm password + policy before updating it + type: bool + aliases: [ validatePasswordPolicy ] + + read_timeout: + description: + - LDAP timeout in milliseconds for read operations + type: int + aliases: [ readTimeout ] + + connection_timeout: + description: + - LDAP connection timeout in milliseconds + type: int + aliases: [ connectionTimeout ] + notes: - - The following parameters existing in the UI are not taken into account in this module, I(importUser), I(validatePasswordPolicy), I(connectionPooling) (and all associated parameters), I(connectionTimeout), I(readTimeout), I(allowKerberosAuthentication) (and all associated parameters), I(useKerberosForPasswordAuthentication), I(batchSize), I(periodicFullSync), I(periodicChangedUserSync) and I(cachePolicy). + - The following parameters existing in the UI are not taken into account in + this module, I(importUser), I(connectionPooling) (and all associated parameters), + I(allowKerberosAuthentication) (and all associated parameters), + I(useKerberosForPasswordAuthentication), I(batchSize) and I(cachePolicy). extends_documentation_fragment: - keycloak @@ -549,7 +571,7 @@ keycloak_argument_spec, KeycloakAuthorizationHeader, ) -from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.common.dict_transformations import dict_merge, recursive_diff from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import quote, urlencode @@ -852,7 +874,12 @@ def _create_payload(self): :rtype: dict """ translation = {'federation_id': 'name', 'federation_uuid': 'id'} - config_translation = {'synchronize_registrations': 'syncRegistrations'} + config_translation = { + 'synchronize_registrations': 'syncRegistrations', + 'full_synchronization_period': 'fullSyncPeriod', + 'changed_user_synchronization_period': 'changedSyncPeriod', + 'batch_size_for_synchronization': 'batchSizeForSync', + } config = {} payload = { 'providerId': 'ldap', @@ -876,7 +903,7 @@ def _create_payload(self): config.update({camel(key): [', '.join(value)]}) else: config.update( - {camel(key).replace('Ldap', 'LDAP'): [str(value)]} + {camel(key).replace('Ldap', 'LDAP'): [value]} ) try: old_configuration = { @@ -895,6 +922,7 @@ def _create_payload(self): 'evictionHour': None, 'evictionMinute': None, 'maxLifespan': None, + 'batchSizeForSync': 1000 } payload.update( { @@ -1075,6 +1103,9 @@ def run_module(): 'changed_user_sync_period', ], ), + validate_password_policy=dict(type='bool', aliases=['validatePasswordPolicy']), + read_timeout=dict(type='int', aliases=['readTimeout']), + connection_timeout=dict(type='int', aliases=['connectionTimeout']), ) # option not taken into account: # cache_policy=dict(type=str, choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN'], aliases=['cachePolicy']) From a5d1c33c03e0dc3778c605628e4790475c83c78d Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Wed, 26 Jun 2019 14:58:30 +0200 Subject: [PATCH 57/79] test: change reference result --- .../modules/identity/keycloak/test_keycloak_ldap_federation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 72a24801a07943..f04ead28701e4a 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -281,6 +281,7 @@ def test_state_present_should_create_absent_federation( 'evictionHour': None, 'evictionMinute': None, 'maxLifespan': None, + 'batchSizeForSync': 1000, }, 'name': 'company-ldap', 'providerId': 'ldap', From 267a4a3e9248993719c4e7701e00b8788a05d22d Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Wed, 26 Jun 2019 11:41:28 +0200 Subject: [PATCH 58/79] create federation synchronization module This is a squash of keycloak synchronization branch doc: add documentation --- .../identity/keycloak/keycloak.py | 111 ++++ .../keycloak/keycloak_ldap_federation.py | 103 ++++ .../keycloak/keycloak_ldap_federation.py | 509 +++++++----------- .../keycloak/keycloak_ldap_synchronization.py | 270 ++++++++++ .../keycloak/test_keycloak_ldap_federation.py | 121 +++-- .../test_keycloak_ldap_synchronisation.py | 295 ++++++++++ 6 files changed, 1054 insertions(+), 355 deletions(-) create mode 100644 lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 7f7a85fdc0efbb..13b29552d0223f 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -28,6 +28,7 @@ from __future__ import absolute_import, division, print_function +from ansible.module_utils._text import to_text from copy import deepcopy __metaclass__ = type @@ -146,6 +147,116 @@ def delete_on_url(url, restheaders, module, description): msg="Could not delete %s in realm %s: %s" % (description, realm, str(e))) +def get_on_url(url, restheaders, module, description): + """Get a keycloak url + :param url: the url to get + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the get object description put in the error message + if the open_url fails + :return: the read json from the url or an empty dictionary if the asked url does not exist. + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + try: + return json.load(open_url(url, method='GET', + headers=restheaders.header, + validate_certs=validate_certs)) + except HTTPError as e: + if e.code == 404: + return {} + else: + module.fail_json( + msg=to_text( + 'Could not obtain %s for realm %s: %s' % (description, realm, e) + ) + ) + except ValueError as e: + module.fail_json( + msg='API returned incorrect JSON when trying to obtain %s for realm %s: %s' + % (description, realm, str(e))) + except Exception as e: + module.fail_json( + msg='Could not obtain %s for realm %s: %s' % (description, realm, str(e))) + + +def put_on_url(url, restheaders, module, description, representation=None): + """Put on a keycloak url + + :param url: the url to put + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the object description put in the error message + if the open_url fails + :param representation: the object as a dictionary to modify on keycloak + """ + call_with_payload_on_url(url, restheaders, module, description, 'PUT', representation) + + +def post_on_url(url, restheaders, module, description, representation=None): + """Push on a keycloak url + + :param url: the url to push + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the object description put in the error message + if the open_url fails + :param representation: the object as a dictionary to create on keycloak + """ + return call_with_payload_on_url(url, restheaders, module, description, 'POST', representation) + + +def call_with_payload_on_url(url, restheaders, module, description, method, representation=None): + """Call on a keycloak url with a payload + :param url: the url to modify + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the object description put in the error message + if the open_url fails + :param method: the url method PUSH or PUT + :param representation: the object as a dictionary to create or modify on keycloak + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + method_verb = { + 'PUT': 'modified', + 'POST': 'created' + } + if not representation: + pushed_data = json.dumps(representation) + else: + pushed_data = {} + try: + return open_url(url, method=method, + headers=restheaders.header, + data=pushed_data, + validate_certs=validate_certs) + except Exception as e: + module.fail_json( + msg=to_text("Could not %s %s in realm %s: %s" % ( + method_verb[method], description, realm, str(e)))) + + +def delete_on_url(url, restheaders, module, description): + """Delete a keycloak url + :param url: the url to delete + :param restheaders: the keycloak restheader with the token + :param module: the module calling this function + :param description: the deleted object description put in the error message + if the open_url fails + :return: the read json from the url + """ + validate_certs = module.params.get('validate_certs') + realm = module.params.get('realm') + try: + return open_url(url, method='DELETE', + headers=restheaders.header, + validate_certs=validate_certs) + except Exception as e: + module.fail_json( + msg="Could not delete %s in realm %s: %s" % (description, realm, str(e))) + + def keycloak_argument_spec(): """ Returns argument_spec of options common to keycloak_*-modules diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py new file mode 100644 index 00000000000000..e39c110001d62b --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py @@ -0,0 +1,103 @@ +# Copyright: (c) 2018, Nicolas Duclert +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from copy import deepcopy + +from ansible.module_utils.identity.keycloak.keycloak import get_on_url +from ansible.module_utils.six.moves.urllib.parse import quote + +USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' +USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' + + +class LdapFederationBase(object): + def __init__(self, module, connection_header): + self.module = module + self.restheaders = connection_header + self.federation = self._clean_payload( + self.get_federation(), credential_clean=False + ) + try: + self.uuid = self.federation['id'] + except KeyError: + self.uuid = '' + + def _get_federation_url(self): + """Create the url in order to get the federation from the given argument (uuid or name) + :return: the url as string + :rtype: str + """ + try: + return USER_FEDERATION_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=self.uuid, + ) + except AttributeError: + if self.module.params.get('federation_id'): + return USER_FEDERATION_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + federation_id=quote(self.module.params.get('federation_id')), + ) + return USER_FEDERATION_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=quote(self.module.params.get('federation_uuid')), + ) + + def get_federation(self): + """Get the federation information from keycloak + + :return: the federation representation as a dictionary, if the asked + representation does not exist, a empty dictionary is returned. + :rtype: dict + """ + json_federation = get_on_url( + url=self._get_federation_url(), + restheaders=self.restheaders, + module=self.module, + description='user federation {}'.format(self.given_id) + ) + if json_federation: + try: + return json_federation[0] + except KeyError: + return json_federation + return {} + + @staticmethod + def _clean_payload(payload, credential_clean=True): + """Clean the payload from credentials and extra list. + + :param payload: the payload given to the post or put request. + :return: the cleaned payload + :rtype: dict + """ + if not payload: + return {} + clean_payload = deepcopy(payload) + old_config = clean_payload.pop('config') + new_config = {} + for key, value in old_config.items(): + if key == 'bindCredential' and credential_clean: + new_config.update({key: 'no_log'}) + else: + try: + new_config.update({key: value[0]}) + except IndexError: + new_config.update({key: None}) + + clean_payload.update({'config': new_config}) + return clean_payload + + @property + def given_id(self): + """Get the asked id given by the user. + + :return the asked id given by the user as a name or an uuid. + :rtype: str + """ + if self.module.params.get('federation_id'): + return self.module.params.get('federation_id') + return self.module.params.get('federation_uuid') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index fe5e3032774190..acfaa3587598e9 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -20,200 +20,200 @@ short_description: Allows administration of Keycloak LDAP federation via Keycloak API description: - - This module allows you to add, remove or modify Keycloak LDAP federation via the Keycloak 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(http://www.keycloak.org/docs-api/3.3/rest-api/). - - - At creation and update, this module allows you to test the connection or the authentication - to the LDAP service from the given arguments. If the connection or the authentication does - not work, the module fails. - - - When updating a LDAP federation, where possible provide the group ID to the module. - This removes a lookup to the API to translate the name into the group ID. + - This module allows you to add, remove or modify Keycloak LDAP federation via the Keycloak 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(http://www.keycloak.org/docs-api/3.3/rest-api/). + + - At creation and update, this module allows you to test the connection or the authentication + to the LDAP service from the given arguments. If the connection or the authentication does + not work, the module fails. + + - When updating a LDAP federation, where possible provide the group ID to the module. + This removes a lookup to the API to translate the name into the group ID. version_added: "2.9" options: - state: - description: - - State of the LDAP federation. - - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide. - - On C(absent), the group will be removed if it exists. - required: true - default: present - type: str - choices: - - present - - absent - - realm: - type: str - description: - - They Keycloak realm under which this LDAP federation resides. - default: 'master' - - federation_id: - description: - - The name of the federation - - Also called ID of the federationin the table of federations or - the console display name in the detailed view of a federation - - This parameter is mutually exclusive with federation_uuid and one - of them is required by the module - type: str - aliases: [ federerationId ] - - federation_uuid: - description: - - The uuid of the federation - - This parameter is mutually exclusive with federation_id and one - of them is required by the module - type: str - aliases: [ federationUuid ] - - enable: - description: - - whether the federation will be enable - type: bool - - pagination: - description: - - Does the LDAP server supports pagination. - type: bool - - vendor: - description: - - LDAP provider - - Mandatory when creating the LDAP federation - choices: - - other - - ad - - rhds - - tivoli - - edirectory - type: str - - username_ldap_attribute: - description: - - Name of the LDAP attribute to map to the Keycloak username - - Mandatory when creating the LDAP federation - type: str - aliases: [ usernameLDAPAttribute, username_LDAP_attribute, usernameLdapAttribute ] - - rdn_ldap_attribute: - description: - - Name of the LDAP attribute to use as top attribute - - Mandatory when creating the LDAP federation - type: str - aliases: [ rdnLDAPAttribute, rdnLdapAttribute, rdn_LDAP_attribute ] - - user_object_classes: - description: - - LDAP object class attributes for users - - Mandatory when creating the LDAP federation - type: list - aliases: userObjectClasses - - connection_url: - description: - - the url of the LDAP service - - Mandatory when creating the LDAP federation - type: str - aliases: [ connectionUrl ] - - users_dn: - description: - - Full DN of LDAP tree where users are - - Mandatory when creating the LDAP federation - type: str - aliases: [ usersDn ] - - bind_dn: - description: - - DN of LDAP admin - - Mandatory when creating the LDAP federation - type: str - aliases: [ bindDn ] - - bind_credential: - description: - - Password of LDAP admin - - Mandatory when creating the LDAP federation - type: str - aliases: [ bindCredential ] - - uuid_ldap_attribute: - description: - - Name of LDAP attribute which is used as unique object identifier - for object in LDAP - - Mandatory when creating the LDAP federation - type: str - aliases: [ uuidLDAPAttribute, uuidLdapAttribute, uuid_LDAP_attribute ] - - edit_mode: - description: - - The behaviour of the Keycloak with the LDAP. - choices: - - READ_ONLY - - UNSYNCED - - WRITABLE - type: str - aliases: [ editMode ] - - import_enable: - description: - - Whether to import the user from the LDAP into the Keycloak databases - type: bool - aliases: [ importEnable ] - - synchronize_registrations: - description: - - Should new user in the Keycloak be created within the LDAP - type: bool - aliases: [ sync_registrations, synchronizeRegistrations, syncRegistrations ] - - customer_user_ldap_filter: - description: - - Filter for searching user in the LDAP - type: str - aliases: [ customUserSearchFilter, custom_user_search_filter, customUserLdapFilter, customUserLDAPFilter, customUserLDAPFilter ] - - search_scope: - description: - - Set how users are search, on one level or in all the subtree - type: str - choices: - - one level - - subtree - aliases: [ searchScope ] - - use_trustore_spi: - description: - - Whether LDAP connection will use the trustore SPI with the trustore conifgure in the standalone.xml - type: str - choices: - - ldapsOnly - - always - - never - aliases: [ useTruststoreSpi ] - - test_connection: - description: - - Check the connection to the LDAP server with a ping - - This parameter is mutually exclusive with test_authentication - type: bool - aliases: [ testConnection ] - - test_authentication: - description: - - Check the connection to the LDAP server with the admin credentials - - This parameter is mutually exclusive with test_connection - type: bool - aliases: [ testAuthentication ] + state: + description: + - State of the LDAP federation. + - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the group will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP federation resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federationin the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with federation_uuid and one + of them is required by the module + type: str + aliases: [ federerationId ] + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with federation_id and one + of them is required by the module + type: str + aliases: [ federationUuid ] + + enable: + description: + - whether the federation will be enable + type: bool + + pagination: + description: + - Does the LDAP server supports pagination. + type: bool + + vendor: + description: + - LDAP provider + - Mandatory when creating the LDAP federation + choices: + - other + - ad + - rhds + - tivoli + - edirectory + type: str + + username_ldap_attribute: + description: + - Name of the LDAP attribute to map to the Keycloak username + - Mandatory when creating the LDAP federation + type: str + aliases: [ usernameLDAPAttribute, username_LDAP_attribute, usernameLdapAttribute ] + + rdn_ldap_attribute: + description: + - Name of the LDAP attribute to use as top attribute + - Mandatory when creating the LDAP federation + type: str + aliases: [ rdnLDAPAttribute, rdnLdapAttribute, rdn_LDAP_attribute ] + + user_object_classes: + description: + - LDAP object class attributes for users + - Mandatory when creating the LDAP federation + type: list + aliases: userObjectClasses + + connection_url: + description: + - the url of the LDAP service + - Mandatory when creating the LDAP federation + type: str + aliases: [ connectionUrl ] + + users_dn: + description: + - Full DN of LDAP tree where users are + - Mandatory when creating the LDAP federation + type: str + aliases: [ usersDn ] + + bind_dn: + description: + - DN of LDAP admin + - Mandatory when creating the LDAP federation + type: str + aliases: [ bindDn ] + + uuid_ldap_attribute: + description: + - Name of LDAP attribute which is used as unique object identifier + for object in LDAP + - Mandatory when creating the LDAP federation + type: str + aliases: [ uuidLDAPAttribute, uuidLdapAttribute, uuid_LDAP_attribute ] + + bind_credential: + description: + - Password of LDAP admin + - Mandatory when creating the LDAP federation + type: str + aliases: [ bindCredential ] + + edit_mode: + description: + - The behaviour of the Keycloak with the LDAP. + choices: + - READ_ONLY + - UNSYNCED + - WRITABLE + type: str + aliases: [ editMode ] + + import_enable: + description: + - Whether to import the user from the LDAP into the Keycloak databases + type: bool + aliases: [ importEnable ] + + synchronize_registrations: + description: + - Should new user in the Keycloak be created within the LDAP + type: bool + aliases: [ sync_registrations, synchronizeRegistrations, syncRegistrations ] + + customer_user_ldap_filter: + description: + - Filter for searching user in the LDAP + type: str + aliases: [ customUserSearchFilter, custom_user_search_filter, customUserLdapFilter, customUserLDAPFilter, customUserLDAPFilter ] + + search_scope: + description: + - Set how users are search, on one level or in all the subtree + type: str + choices: + - one level + - subtree + aliases: [ searchScope ] + + use_trustore_spi: + description: + - Whether LDAP connection will use the trustore SPI with the trustore configure in the standalone.xml + type: str + choices: + - ldapsOnly + - always + - never + aliases: [ useTruststoreSpi ] + + test_connection: + description: + - Check the connection to the LDAP server with a ping + - This parameter is mutually exclusive with test_authentication + type: bool + aliases: [ testConnection ] + + test_authentication: + description: + - Check the connection to the LDAP server with the admin credentials + - This parameter is mutually exclusive with test_connection + type: bool + aliases: [ testAuthentication ] batch_size_for_synchronization: description: @@ -261,10 +261,10 @@ I(useKerberosForPasswordAuthentication), I(batchSize) and I(cachePolicy). extends_documentation_fragment: - - keycloak + - keycloak author: - - Nicolas Duclert (@ndclt) + - Nicolas Duclert (@ndclt) ''' EXAMPLES = r''' @@ -571,7 +571,8 @@ keycloak_argument_spec, KeycloakAuthorizationHeader, ) -from ansible.module_utils.common.dict_transformations import dict_merge, recursive_diff +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import LdapFederationBase +from ansible.module_utils.common.dict_transformations import dict_merge from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import quote, urlencode @@ -585,102 +586,12 @@ SEARCH_SCOPE = {'one level': 1, 'subtree': 2} -class LdapFederation(object): +class LdapFederation(LdapFederationBase): """Keycloak LDAP Federation class. """ def __init__(self, module, connection_header): - self.module = module - self.restheaders = connection_header - self.federation = self._clean_payload( - self.get_federation(), credential_clean=False - ) - try: - self.uuid = self.federation['id'] - except KeyError: - self.uuid = '' - - def _get_federation_url(self): - """Create the url in order to get the federation from the given argument (uuid or name) - :return: the url as string - :rtype: str - """ - try: - return USER_FEDERATION_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=self.uuid, - ) - except AttributeError: - if self.module.params.get('federation_id'): - return USER_FEDERATION_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - federation_id=quote(self.module.params.get('federation_id')), - ) - return USER_FEDERATION_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=quote(self.module.params.get('federation_uuid')), - ) - - def get_federation(self): - """Get the federation information from keycloak - - :return: the federation representation as a dictionary, if the asked - representation does not exist, a empty dictionary is returned. - :rtype: dict - """ - get_url = self._get_federation_url() - realm = self.module.params.get('realm') - try: - json_federation = json.load( - open_url( - get_url, - method='GET', - headers=self.restheaders.header, - validate_certs=self.module.params.get('validate_certs'), - ) - ) - except HTTPError as e: - if e.code == 404: - return {} - else: - self.module.fail_json( - msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) - ) - except ValueError as e: - self.module.fail_json( - msg=( - 'API returned incorrect JSON when trying to obtain user ' - 'federation %s for realm %s: %s' - ) - % (to_text(self.given_id), to_text(realm), to_text(e)) - ) - except Exception as e: - self.module.fail_json( - msg='Could not obtain user federation %s for realm %s: %s' - % (to_text(self.given_id), to_text(realm), to_text(e)) - ) - else: - if json_federation: - try: - return json_federation[0] - except KeyError: - return json_federation - return {} - - @property - def given_id(self): - """Get the asked id given by the user. - - :return the asked id given by the user as a name or an uuid. - :rtype: str - """ - if self.module.params.get('federation_id'): - return self.module.params.get('federation_id') - return self.module.params.get('federation_uuid') + super(LdapFederation, self).__init__(module, connection_header) def delete(self): """Delete the federation""" @@ -796,6 +707,9 @@ def _call_test_url(self, extra_arguments): The connection or authentication failure is identified with the 400 http status code. + The payload is build from module arguments and if they are not given + (for an update) there are taken from the federation representation. + :param extra_arguments: a dictionary with the action to do ( authentication or connection) :return: a boolean showing if the connection or the authentication @@ -953,31 +867,6 @@ def get_result(self): """ return self._clean_payload(self._create_payload()) - @staticmethod - def _clean_payload(payload, credential_clean=True): - """Clean the payload from credentials and extra list. - - :param payload: the payload given to the post or put request. - :return: the cleaned payload - :rtype: dict - """ - if not payload: - return {} - clean_payload = deepcopy(payload) - old_config = clean_payload.pop('config') - new_config = {} - for key, value in old_config.items(): - if key == 'bindCredential' and credential_clean: - new_config.update({key: 'no_log'}) - else: - try: - new_config.update({key: value[0]}) - except IndexError: - new_config.update({key: None}) - - clean_payload.update({'config': new_config}) - return clean_payload - def check_mandatory_arguments(self, creation_payload): """Check if mandatory arguments for federation creation are present. diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py new file mode 100644 index 00000000000000..a961f3c1b07067 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -0,0 +1,270 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +import json + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import AnsibleModule + +__metaclass__ = type + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = r''' +--- +module: keycloak_ldap_synchronization + +short_description: Allows synchronization operations of Keycloak with a LDAP server via Keycloak API + +description: + - This module allows you to synchronized users with an LDAP server describe + in the LDAP federation. The authorized operations are changed user or full + synchronization, remove the imported users or unlink the imported users + from the LDAP. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(http://www.keycloak.org/docs-api/4.8/rest-api/). + +version_added: "2.9" + +options: + realm: + type: str + description: + - They Keycloak realm under which the LDAP federation to synchronize resides. + default: 'master' + + federation_id: + description: + - The name of the federation to synchronize + - Also called ID of the federation in the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with I(federation_uuid) and one + of them is required by the module + type: str + aliases: [ federerationId ] + + federation_uuid: + description: + - The uuid of the federation to synchronize + - This parameter is mutually exclusive with I(federation_id) and one + of them is required by the module + type: str + aliases: [ federationUuid ] + + synchronize_changed_users: + description: + - Whether to synchronized only changed users + - This parameter is mutually exclusive with I(synchronize_all_users), + I(remove_imported), I(unlink_users) and one of them is required by the + module. + type: bool + aliases: [ sync_changed_users, syncChangedUsers, synchronizeChangedUsers ] + + synchronize_all_users: + description: + - Whether to synchronized all users (delete extra users on the Keycloak, + import new users from the LDAP). + - This parameter is mutually exclusive with I(synchronize_changed_users), + I(remove_imported), I(unlink_users) and one of them is required by the + module. + type: bool + aliases: [ sync_all_users, synchronizeAllUsers, syncAllUsers ] + + remove_imported: + description: + - Whether to delete users imported from the Keycloak. + - This parameter is mutually exclusive with I(synchronize_changed_users), + I(synchronize_all_users), I(unlink_users) and one of them is required + by the module. + type: bool + aliases: [ removeImported ] + + unlink_users: + description: + - Whether to cut the link between the imported users and the Keycloak, + this will prevent an update from the LDAP. + - This parameter is mutually exclusive with I(synchronize_changed_users), + I(synchronize_all_users), I(remove_imported) and one of them is required + by the module. + type: bool + aliases: [ unlinkUsers ] + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = r''' +- name: Synchronize all the users from the LDAP + keycloak_ldap_synchronization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + federation_id: my-company-ldap + synchronize_all_users: True +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "2 imported users, 2 updated users, 1 removed." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool +''' + +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( + LdapFederationBase, +) +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + KeycloakAuthorizationHeader, + post_on_url, +) + +ALL_OPERATIONS = ['synchronize_changed_users', 'synchronize_all_users', 'remove_imported', 'unlink_users'] + + +class LdapSynchronization(LdapFederationBase): + def __init__(self, module, connection_header): + super(LdapSynchronization, self).__init__(module, connection_header) + self.message = '' + self.changed = True + if not self.uuid: + self.module.fail_json( + msg=to_text( + 'Cannot synchronize {} federation because it does not exist.'.format( + self.given_id + ) + ) + ) + + def synchronize(self): + synchronize_url = self._build_url() + response = post_on_url(synchronize_url, self.restheaders, self.module, '{} for {}'.format(self.operation, self.given_id)) + try: + synchronisation_result = json.load(response) + except AttributeError: + # This happens because the delete and unlink don't return a content. + synchronisation_result = {} + self._create_message(synchronisation_result) + self._udpate_changed(synchronisation_result) + + def _build_url(self): + url_dict = { + ALL_OPERATIONS[0]: '{base_url}/admin/realms/{realm}/user-storage/{federation_uuid}/sync?action=triggerChangedUsersSync', + ALL_OPERATIONS[1]: '{base_url}/admin/realms/{realm}/user-storage/{federation_uuid}/sync?action=triggerFullSync', + ALL_OPERATIONS[2]: '{base_url}/admin/realms/{realm}/user-storage/{federation_uuid}/remove-imported-users', + ALL_OPERATIONS[3]: '{base_url}/admin/realms/{realm}/user-storage/{federation_uuid}/unlink-users', + } + return url_dict[self.operation].format( + base_url=self.module.params.get('auth_keycloak_url'), + realm=self.module.params.get('realm'), + federation_uuid=self.uuid, + ) + + @property + def operation(self): + for one_name in ALL_OPERATIONS: + potential_operation = self.module.params.get(one_name) + if potential_operation: + return one_name + + def _create_message(self, synchronisation_result): + # only the synchronization return a dictionary + if synchronisation_result: + self.message = synchronisation_result['status'] + '.' + else: + if self.operation == ALL_OPERATIONS[2]: + self.message = 'Remove imported users from {}.'.format(self.given_id) + elif self.operation == ALL_OPERATIONS[3]: + self.message = 'Unlink users of {}.'.format(self.given_id) + else: + raise ValueError('The operation does not have a message.') + + def _udpate_changed(self, synchronisation_result): + # if the post does not return a response, it is a call to unlink or + # remove. The changed status have to stay to True.. + if synchronisation_result: + no_user_is_changed = ( + synchronisation_result['added'] == 0 + and synchronisation_result['updated'] == 0 + and synchronisation_result['removed'] == 0 + ) + if synchronisation_result['ignored'] or no_user_is_changed: + self.changed = False + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + realm=dict(type='str', default='master'), + federation_id=dict(type='str', aliases=['federerationId']), + federation_uuid=dict(type='str', aliases=['federationUuid']), + synchronize_changed_users=dict(type='bool', aliases=['sync_changed_users', 'syncChangedUsers', 'synchronizeChangedUsers']), + synchronize_all_users=dict(type='bool', aliases=['sync_all_users', 'synchronizeAllUsers', 'syncAllUsers']), + remove_imported=dict(type='bool', aliases=['removeImported']), + unlink_users=dict(type='bool', aliases=['unlinkUsers']), + ) + argument_spec.update(meta_args) + + # The id of the role is unique in keycloak and if it is given the + # client_id is not used. In order to avoid confusion, I set a mutual + # exclusion. + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[ + ['federation_id', 'federation_uuid'], + ALL_OPERATIONS, + ], + mutually_exclusive=[ + ['federation_id', 'federation_uuid'], + ALL_OPERATIONS, + ], + ) + connection_header = KeycloakAuthorizationHeader( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + ldap_synchronization = LdapSynchronization(module, connection_header) + ldap_synchronization.synchronize() + result = { + 'changed': ldap_synchronization.changed, + 'msg': to_text(ldap_synchronization.message), + } + if 'failed' in ldap_synchronization.message: + module.fail_json(**result) + else: + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index f04ead28701e4a..818375b0a090dd 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -2,6 +2,7 @@ from __future__ import absolute_import, division, print_function import json +from copy import deepcopy from itertools import count, filterfalse import pytest @@ -15,7 +16,6 @@ exit_json, set_module_args, ) -from ansible.module_utils._text import to_text from ansible.module_utils.common.dict_transformations import recursive_diff from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.six.moves.urllib.parse import urlencode @@ -88,7 +88,8 @@ def _raise_404(): @pytest.fixture def mock_absent_url(mocker): - absent_federation = { + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not_here': create_wrapper( json.dumps([]) ), @@ -98,9 +99,9 @@ def mock_absent_url(mocker): 'http://keycloak.url/auth/admin/realms/master/components/123-123': raise_404( 'http://keycloak.url/auth/admin/realms/master/components/123-123' ), - } + }) return mocker.patch( - 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), absent_federation), autospec=True, ) @@ -115,7 +116,7 @@ def mock_absent_url(mocker): ], ) def test_state_absent_should_not_create_absent_federation( - monkeypatch, mock_absent_url, mock_get_token, extra_arguments + monkeypatch, mock_absent_url, extra_arguments ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -142,12 +143,10 @@ def test_state_absent_should_not_create_absent_federation( assert not ansible_exit_json['ldap_federation'] -@pytest.fixture -def mock_delete_url(mocker): - # This fixture does not return a full federation json, just an extract - # with parts needed in the test and some value in order to have object - # organisation. - delete_federation = { +@pytest.fixture() +def mock_get_for_delete(mocker): + get_for_delete = deepcopy(CONNECTION_DICT) + get_for_delete.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=ldap-to-delete': create_wrapper( json.dumps( [ @@ -160,18 +159,32 @@ def mock_delete_url(mocker): ] ) ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps( + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + }) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), get_for_delete), + autospec=True, + ) + + +@pytest.fixture +def mock_delete_url(mocker): + # This fixture does not return a full federation json, just an extract + # with parts needed in the test and some value in order to have object + # organisation. + delete_federation = { 'http://keycloak.url/auth/admin/realms/master/components/123-123': { 'DELETE': None, - 'GET': create_wrapper( - json.dumps( - { - 'id': '123-123', - 'name': 'ldap-to-delete', - 'parentId': 'master', - 'config': {'pagination': [True]}, - } - ) - ), }, } return mocker.patch( @@ -186,7 +199,7 @@ def mock_delete_url(mocker): [{'federation_id': 'ldap-to-delete'}, {'federation_uuid': '123-123'}], ) def test_state_absent_should_delete_existing_federation( - monkeypatch, extra_arguments, mock_delete_url, mock_get_token + monkeypatch, extra_arguments, mock_delete_url, mock_get_for_delete ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -214,9 +227,6 @@ def test_state_absent_should_delete_existing_federation( @pytest.fixture() def mock_create_url(mocker): create_federation = { - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps([]) - ), 'http://keycloak.url/auth/admin/realms/master/components/': None, } return mocker.patch( @@ -226,8 +236,23 @@ def mock_create_url(mocker): ) +@pytest.fixture() +def mock_get_for_create(mocker): + get_federation = deepcopy(CONNECTION_DICT) + get_federation.update({ + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ), + }) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), get_federation), + autospec=True, + ) + + def test_state_present_should_create_absent_federation( - monkeypatch, mock_create_url, mock_get_token + monkeypatch, mock_create_url, mock_get_for_create ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -299,13 +324,13 @@ def test_state_present_should_create_absent_federation( else: send_config.update({key: [value]}) reference_result.update({'config': send_config}) - create_call = mock_create_url.mock_calls[1] + create_call = mock_create_url.mock_calls[0] send_json = json.loads(create_call[2]['data']) diff_result = recursive_diff(send_json, reference_result) assert not diff_result -def test_create_payload_all_mandatory(monkeypatch, mock_absent_url, mock_get_token): +def test_create_payload_all_mandatory(monkeypatch, mock_absent_url): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) arguments = { @@ -331,9 +356,6 @@ def test_create_payload_all_mandatory(monkeypatch, mock_absent_url, mock_get_tok @pytest.fixture() def mock_create_url_with_check(mocker): create_federation = { - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps([]) - ), 'http://keycloak.url/auth/admin/realms/master/components/': None, 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, } @@ -350,7 +372,7 @@ def mock_create_url_with_check(mocker): ids=['connection only', 'authentication'], ) def test_arguments_check_connectivity_should_try_ldap_connection( - monkeypatch, extra_arguments, mock_create_url_with_check, mock_get_token + monkeypatch, extra_arguments, mock_create_url_with_check, mock_get_for_create ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -409,9 +431,6 @@ def mock_wrong_authentication_url(mocker, request): ], } create_federation = { - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps([]) - ), 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': ldap_connection[ request.node.callspec.id ], @@ -453,7 +472,7 @@ def test_wrong_ldap_credentials_should_raise_an_error( extra_arguments, waited_message, mock_wrong_authentication_url, - mock_get_token, + mock_get_for_create, ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -484,6 +503,20 @@ def test_wrong_ldap_credentials_should_raise_an_error( @pytest.fixture() def mock_update_url(mocker): update_federation = { + 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, + } + return mocker.patch( + 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + side_effect=build_mocked_request(count(), update_federation), + autospec=True, + ) + + +@pytest.fixture() +def mock_get_for_update(mocker): + get_federation = deepcopy(CONNECTION_DICT) + get_federation.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( json.dumps( [ @@ -510,18 +543,16 @@ def mock_update_url(mocker): ] ) ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, - } + }) return mocker.patch( - 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', - side_effect=build_mocked_request(count(), update_federation), + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), get_federation), autospec=True, ) def test_state_present_should_update_existing_federation( - monkeypatch, mock_get_token, mock_update_url + monkeypatch, mock_get_for_update, mock_update_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -568,7 +599,7 @@ def test_state_present_should_update_existing_federation( def test_state_present_should_update_existing_federation_with_connect_check( - monkeypatch, mock_get_token, mock_update_url + monkeypatch, mock_get_for_update, mock_update_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -594,7 +625,7 @@ def test_state_present_should_update_existing_federation_with_connect_check( assert urlencode({'bindDn': 'cn:admin'}) in send_data -def test_create_payload_for_synchronization(monkeypatch, mock_get_token, mock_update_url): +def test_create_payload_for_synchronization(monkeypatch, mock_get_for_update, mock_update_url): """When updating the sync registration of a federation, the payload needs to have some keys. If not, the response is 204 put the sync registration parameter is not updated.""" @@ -614,7 +645,7 @@ def test_create_payload_for_synchronization(monkeypatch, mock_get_token, mock_up set_module_args(arguments) with pytest.raises(AnsibleExitJson): keycloak_ldap_federation.run_module() - put_call = mock_update_url.mock_calls[1] + put_call = mock_update_url.mock_calls[0] pushed_data = json.loads(put_call[2]['data']) config = pushed_data.pop('config') all_config_keys = list(config.keys()) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py new file mode 100644 index 00000000000000..f1b35db22a72a6 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py @@ -0,0 +1,295 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +import json +from copy import deepcopy +from itertools import count + +from ansible.module_utils._text import to_text +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) +from ansible.modules.identity.keycloak import keycloak_ldap_synchronization + +import pytest + +from ansible.module_utils.six import StringIO + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response( + object_with_future_response[call_number], method, get_id_call_count + ) + return object_with_future_response + + +@pytest.fixture +def mock_get_token(mocker): + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), CONNECTION_DICT), + autospec=True, + ) + + +@pytest.fixture +def mock_absent_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=does-not-exist': create_wrapper( + json.dumps([]) + ) + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_federation_does_not_exist_fail(monkeypatch, mock_absent_url): + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'does-not-exist', + 'synchronize_changed_users': True, + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_synchronization.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == to_text( + 'Cannot synchronize does-not-exist federation because it does not exist.' + ) + + +@pytest.fixture +def mock_synchronisation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps( + [ + { + 'id': '123-123', + 'name': 'company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True], 'bindDn': ['cn:admin']}, + } + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/sync?action=triggerChangedUsersSync': create_wrapper( + json.dumps( + { + 'ignored': False, + 'added': 0, + 'updated': 2, + 'removed': 1, + 'failed': 0, + 'status': '0 imported users, 2 updated users', + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/sync?action=triggerFullSync': create_wrapper( + json.dumps( + { + 'ignored': False, + 'added': 2, + 'updated': 2, + 'removed': 1, + 'failed': 0, + 'status': '2 imported users, 2 updated users, 1 removed', + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/remove-imported-users': None, + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': None, + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments, waited_message, url_keyword', + [ + ( + {'synchronize_all_users': True}, + '2 imported users, 2 updated users, 1 removed.', + 'triggerFullSync', + ), + ( + {'synchronize_changed_users': True}, + '0 imported users, 2 updated users.', + 'triggerChangedUsersSync', + ), + ( + {'remove_imported': True}, + 'Remove imported users from company-ldap.', + 'remove-imported-users', + ), + ({'unlink_users': True}, 'Unlink users of company-ldap.', 'unlink-users'), + ], + ids=[ + 'Synchronize all users', + 'Synchronize changed users', + 'Remove users', + 'Unlink users', + ], +) +def test_synchronize_change_user( + monkeypatch, extra_arguments, waited_message, url_keyword, mock_synchronisation_url +): + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'company-ldap', + } + arguments.update(extra_arguments) + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_synchronization.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == to_text(waited_message) + calls = mock_synchronisation_url.mock_calls + urls = [one_call[1][0] for one_call in calls] + keyword_found = False + for one_url in urls: + if url_keyword in one_url: + keyword_found = True + assert keyword_found + + +@pytest.fixture +def mock_fail_synchronisation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps( + [ + { + 'id': '123-123', + 'name': 'company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True], 'bindDn': ['cn:admin']}, + } + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/sync?action=triggerFullSync': create_wrapper( + json.dumps( + { + 'ignored': False, + 'added': 0, + 'updated': 0, + 'removed': 0, + 'failed': 5, + 'status': '0 imported users, 0 updated users, 5 users failed sync! See server log for more details', + } + ) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_fail_synchronisation(monkeypatch, mock_fail_synchronisation_url): + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json + ) + monkeypatch.setattr( + keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json + ) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'company-ldap', + 'synchronize_all_users': True, + } + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_synchronization.run_module() + ansible_exit_json = exec_error.value.args[0] + assert not ansible_exit_json['changed'] + assert ansible_exit_json['msg'] == to_text( + '0 imported users, 0 updated users, 5 users failed sync! See server log for more details.' + ) From f84b8b5874442b1ff3da30c0069e5de2b4de3346 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 27 Jun 2019 16:27:05 +0200 Subject: [PATCH 59/79] test: change the mocked url in order to be closer from the real situation --- .../identity/keycloak/test_keycloak_ldap_synchronisation.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py index f1b35db22a72a6..b82d0167b23e24 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py @@ -160,8 +160,8 @@ def mock_synchronisation_url(mocker): } ) ), - 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/remove-imported-users': None, - 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': None, + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/remove-imported-users': create_wrapper(''), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': create_wrapper(''), } ) return mocker.patch( From 1fd15bdcf6d9d7c59971610cab9698be81c8be82 Mon Sep 17 00:00:00 2001 From: Nicolas Duclert Date: Thu, 27 Jun 2019 16:27:19 +0200 Subject: [PATCH 60/79] dev: manage json decode error --- .../keycloak/keycloak_ldap_synchronization.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py index a961f3c1b07067..af9a8c27271a88 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -5,11 +5,6 @@ # 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 -import json - -from ansible.module_utils._text import to_text -from ansible.module_utils.basic import AnsibleModule - __metaclass__ = type ANSIBLE_METADATA = { @@ -131,6 +126,12 @@ type: bool ''' +import json +from json import JSONDecodeError + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import AnsibleModule + from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( LdapFederationBase, ) @@ -162,7 +163,7 @@ def synchronize(self): response = post_on_url(synchronize_url, self.restheaders, self.module, '{} for {}'.format(self.operation, self.given_id)) try: synchronisation_result = json.load(response) - except AttributeError: + except JSONDecodeError: # This happens because the delete and unlink don't return a content. synchronisation_result = {} self._create_message(synchronisation_result) From ba86d381f73a8e464276ccb4894840bf839422da Mon Sep 17 00:00:00 2001 From: ndclt Date: Tue, 2 Jul 2019 22:29:35 +0200 Subject: [PATCH 61/79] dev: don't add in the configuration the test_authentication --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index acfaa3587598e9..e6083743c7dd06 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -802,6 +802,7 @@ def _create_payload(self): not_federation_argument = list(keycloak_argument_spec().keys()) + [ 'state', 'realm', + 'test_authentication' ] for key, value in self.module.params.items(): if value is not None and key not in not_federation_argument: From dfb3d78130e29cc8840bd6e10f0254b86143ecb6 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Thu, 5 Sep 2019 17:44:22 +0200 Subject: [PATCH 62/79] dev: delete enable parameter --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index e6083743c7dd06..833e02139ce89d 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -75,11 +75,6 @@ type: str aliases: [ federationUuid ] - enable: - description: - - whether the federation will be enable - type: bool - pagination: description: - Does the LDAP server supports pagination. @@ -911,7 +906,6 @@ def run_module(): realm=dict(type='str', default='master'), federation_id=dict(type='str', aliases=['federerationId']), federation_uuid=dict(type='str', aliases=['federationUuid']), - enable=dict(type='bool'), pagination=dict(type='bool'), vendor=dict( type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory'] From d02747e16a9cfeb2562374828e564b1367d8aa46 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 9 Sep 2019 11:28:06 +0200 Subject: [PATCH 63/79] refactor: get the function from devel --- .../identity/keycloak/keycloak.py | 70 +++++++++---------- .../keycloak/test_keycloak_connect.py | 12 ++-- 2 files changed, 38 insertions(+), 44 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 13b29552d0223f..cbb3725f578edd 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -319,45 +319,39 @@ class KeycloakError(Exception): pass -class KeycloakAuthorizationHeader(object): - def __init__(self, base_url, validate_certs, auth_realm, client_id, - auth_username, auth_password, client_secret): - self.validate_certs = validate_certs - self.auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) - temp_payload = { - 'grant_type': 'password', - 'client_id': client_id, - 'client_secret': client_secret, - 'username': auth_username, - 'password': auth_password, - } - # Remove empty items, for instance missing client_secret - self.payload = dict( - (k, v) for k, v in temp_payload.items() if v is not None) - self.header = {} - self.refresh_token() - - def refresh_token(self): - try: - r = json.load(open_url(self.auth_url, method='POST', - validate_certs=self.validate_certs, - data=urlencode(self.payload))) - except ValueError as e: - raise KeycloakError( - 'API returned invalid JSON when trying to obtain access token from %s: %s' - % (self.auth_url, str(e))) - except Exception as e: - raise KeycloakError('Could not obtain access token from %s: %s' - % (self.auth_url, str(e))) +def get_token(base_url, validate_certs, auth_realm, client_id, + auth_username, auth_password, client_secret): + auth_url = URL_TOKEN.format(url=base_url, realm=auth_realm) + temp_payload = { + 'grant_type': 'password', + 'client_id': client_id, + 'client_secret': client_secret, + 'username': auth_username, + 'password': auth_password, + } + # Remove empty items, for instance missing client_secret + payload = dict( + (k, v) for k, v in temp_payload.items() if v is not None) + try: + r = json.load(open_url(auth_url, method='POST', + validate_certs=validate_certs, + data=urlencode(payload))) + except ValueError as e: + raise KeycloakError( + 'API returned invalid JSON when trying to obtain access token from %s: %s' + % (auth_url, str(e))) + except Exception as e: + raise KeycloakError('Could not obtain access token from %s: %s' + % (auth_url, str(e))) - try: - self.header = { - 'Authorization': 'Bearer ' + r['access_token'], - 'Content-Type': 'application/json' - } - except KeyError: - raise KeycloakError( - 'Could not obtain access token from %s' % self.auth_url) + try: + return { + 'Authorization': 'Bearer ' + r['access_token'], + 'Content-Type': 'application/json' + } + except KeyError: + raise KeycloakError( + 'Could not obtain access token from %s' % auth_url) class KeycloakAPI(object): diff --git a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py index 6fb15fc4669625..3ad79f650bd1a2 100644 --- a/test/units/module_utils/identity/keycloak/test_keycloak_connect.py +++ b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py @@ -4,7 +4,7 @@ from itertools import count from ansible.module_utils.identity.keycloak.keycloak import ( - KeycloakAuthorizationHeader, + get_token, KeycloakError, ) from ansible.module_utils.six import StringIO @@ -58,7 +58,7 @@ def mock_good_connection(mocker): def test_connect_to_keycloak(mock_good_connection): - keycloak_header = KeycloakAuthorizationHeader( + keycloak_header = get_token( base_url='http://keycloak.url/auth', validate_certs=True, auth_realm='master', @@ -67,7 +67,7 @@ def test_connect_to_keycloak(mock_good_connection): auth_password='admin', client_secret=None ) - assert keycloak_header.header == { + assert keycloak_header == { 'Authorization': 'Bearer alongtoken', 'Content-Type': 'application/json' } @@ -86,7 +86,7 @@ def mock_bad_json_returned(mocker): def test_bad_json_returned(mock_bad_json_returned): with pytest.raises(KeycloakError) as raised_error: - KeycloakAuthorizationHeader( + get_token( base_url='http://keycloak.url/auth', validate_certs=True, auth_realm='master', @@ -124,7 +124,7 @@ def mock_401_returned(mocker): def test_error_returned(mock_401_returned): with pytest.raises(KeycloakError) as raised_error: - KeycloakAuthorizationHeader( + get_token( base_url='http://keycloak.url/auth', validate_certs=True, auth_realm='master', @@ -153,7 +153,7 @@ def mock_json_without_token_returned(mocker): def test_json_without_token_returned(mock_json_without_token_returned): with pytest.raises(KeycloakError) as raised_error: - KeycloakAuthorizationHeader( + get_token( base_url='http://keycloak.url/auth', validate_certs=True, auth_realm='master', From 635fd7638101867820ed906041fbf29c12cbdb98 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 9 Sep 2019 11:34:24 +0200 Subject: [PATCH 64/79] refactor: restheader is directly the dict not an object --- .../identity/keycloak/keycloak.py | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index cbb3725f578edd..a94ebfd72d9c4c 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -89,7 +89,7 @@ def get_on_url(url, restheaders, module, description): realm = module.params.get('realm') try: return json.load(open_url(url, method='GET', - headers=restheaders.header, + headers= restheaders, validate_certs=validate_certs)) except ValueError as e: module.fail_json( @@ -118,7 +118,7 @@ def put_on_url(url, restheaders, module, description, representation=None): pushed_data = {} try: return open_url(url, method='PUT', - headers=restheaders.header, + headers= restheaders, data=pushed_data, validate_certs=validate_certs) except Exception as e: @@ -140,7 +140,7 @@ def delete_on_url(url, restheaders, module, description): realm = module.params.get('realm') try: return open_url(url, method='DELETE', - headers=restheaders.header, + headers= restheaders, validate_certs=validate_certs) except Exception as e: module.fail_json( @@ -160,7 +160,7 @@ def get_on_url(url, restheaders, module, description): realm = module.params.get('realm') try: return json.load(open_url(url, method='GET', - headers=restheaders.header, + headers=restheaders, validate_certs=validate_certs)) except HTTPError as e: if e.code == 404: @@ -228,7 +228,7 @@ def call_with_payload_on_url(url, restheaders, module, description, method, repr pushed_data = {} try: return open_url(url, method=method, - headers=restheaders.header, + headers= restheaders, data=pushed_data, validate_certs=validate_certs) except Exception as e: @@ -250,7 +250,7 @@ def delete_on_url(url, restheaders, module, description): realm = module.params.get('realm') try: return open_url(url, method='DELETE', - headers=restheaders.header, + headers=restheaders, validate_certs=validate_certs) except Exception as e: module.fail_json( @@ -376,7 +376,7 @@ def get_clients(self, realm='master', filter=None): clientlist_url += '?clientId=%s' % filter try: - return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders.header, + return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' @@ -407,7 +407,7 @@ def get_client_by_id(self, id, realm='master'): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return json.load(open_url(client_url, method='GET', headers=self.restheaders.header, + return json.load(open_url(client_url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except HTTPError as e: if e.code == 404: @@ -445,7 +445,7 @@ def update_client(self, id, clientrep, realm="master"): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='PUT', headers=self.restheaders.header, + return open_url(client_url, method='PUT', headers=self.restheaders, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update client %s in realm %s: %s' @@ -460,7 +460,7 @@ def create_client(self, clientrep, realm="master"): client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) try: - return open_url(client_url, method='POST', headers=self.restheaders.header, + return open_url(client_url, method='POST', headers=self.restheaders, data=json.dumps(clientrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create client %s in realm %s: %s' @@ -476,7 +476,7 @@ def delete_client(self, id, realm="master"): client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(client_url, method='DELETE', headers=self.restheaders.header, + return open_url(client_url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete client %s in realm %s: %s' @@ -491,7 +491,7 @@ def get_client_templates(self, realm='master'): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' @@ -510,7 +510,7 @@ def get_client_template_by_id(self, id, realm='master'): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' @@ -556,7 +556,7 @@ def update_client_template(self, id, clienttrep, realm="master"): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='PUT', headers=self.restheaders.header, + return open_url(url, method='PUT', headers=self.restheaders, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update client template %s in realm %s: %s' @@ -571,7 +571,7 @@ def create_client_template(self, clienttrep, realm="master"): url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='POST', headers=self.restheaders.header, + return open_url(url, method='POST', headers=self.restheaders, data=json.dumps(clienttrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create client template %s in realm %s: %s' @@ -587,7 +587,7 @@ def delete_client_template(self, id, realm="master"): url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(url, method='DELETE', headers=self.restheaders.header, + return open_url(url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' @@ -602,7 +602,7 @@ def get_realm_by_name(self, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except HTTPError as e: @@ -626,7 +626,7 @@ def create_realm(self, realmrep): url = URL_REALMS.format(url=self.baseurl) try: - return open_url(url, method='POST', headers=self.restheaders.header, + return open_url(url, method='POST', headers=self.restheaders, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create realm: %s' @@ -642,7 +642,7 @@ def update_realm(self, realmrep, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='PUT', headers=self.restheaders.header, + return open_url(url, method='PUT', headers=self.restheaders, data=json.dumps(realmrep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update realm %s: %s' @@ -657,7 +657,7 @@ def delete_realm(self, realm): url = URL_REALM.format(url=self.baseurl, realm=realm) try: - return open_url(url, method='DELETE', headers=self.restheaders.header, + return open_url(url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete realm %s: %s' @@ -675,7 +675,7 @@ def get_scope_mappings(self, id, target='client', realm='master'): url = URL_CLIENT_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mappings for %s in realm %s: %s' @@ -701,7 +701,7 @@ def get_scope_mapping(self, id, id_client=None, target='client', realm='master') url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain scope mapping for %s in realm %s: %s' @@ -728,7 +728,7 @@ def get_available_roles(self, id, id_client=None, target='client', realm='master url = URL_CLIENT_SCOPE_MAPPINGS_REALM_AVAILABLE.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return json.load(open_url(url, method='GET', headers=self.restheaders.header, + return json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except ValueError as e: self.module.fail_json(msg='API returned incorrect JSON when trying to obtain available roles for realm %s: %s' @@ -755,7 +755,7 @@ def create_scope_mapping(self, id, roles, id_client=None, target='client', realm url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return open_url(url, method='POST', headers=self.restheaders.header, data=json.dumps(roles), + return open_url(url, method='POST', headers=self.restheaders, data=json.dumps(roles), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' @@ -779,7 +779,7 @@ def delete_scope_mapping(self, id, roles, id_client=None, target='client', realm url = URL_CLIENT_SCOPE_MAPPINGS_REALM.format(url=self.baseurl, realm=realm, id=id, target=target) try: - return open_url(url, method='DELETE', headers=self.restheaders.header, data=json.dumps(roles), + return open_url(url, method='DELETE', headers=self.restheaders, data=json.dumps(roles), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create/update scope mapping for %s in realm %s: %s' @@ -807,7 +807,7 @@ def _modify_link_between_group_and_role( group_id=group_uuid ) try: - return open_url(url=url, method=method, headers=self.restheaders.header, + return open_url(url=url, method=method, headers=self.restheaders, validate_certs=self.validate_certs, data=json.dumps([role])) except Exception as e: self.module.fail_json( @@ -825,7 +825,7 @@ def get_users(self, realm='master', filter=None): if filter is not None: userlist_url += '?userId=%s' % filter try: - user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders.header, + user_json = json.load(open_url(userlist_url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) return user_json except ValueError as e: @@ -845,7 +845,7 @@ def get_user_by_id(self, id, realm='master'): url = URL_USER.format(url=self.baseurl, id=id, realm=realm) try: return json.load( - open_url(url, method='GET', headers=self.restheaders.header, validate_certs=self.validate_certs)) + open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except HTTPError as e: if e.code == 404: return None @@ -911,7 +911,7 @@ def create_user(self, user_representation, realm="master"): user_representation = self._put_values_in_list(user_representation, ['credentials']) try: - return open_url(user_url, method='POST', headers=self.restheaders.header, + return open_url(user_url, method='POST', headers=self.restheaders, data=json.dumps(user_representation), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not create user %s in realm %s: %s' @@ -937,7 +937,7 @@ def update_user(self, uuid, user_representation, realm="master"): user_url = URL_USER.format(url=self.baseurl, realm=realm, id=uuid) try: - return open_url(user_url, method='PUT', headers=self.restheaders.header, + return open_url(user_url, method='PUT', headers=self.restheaders, data=json.dumps(user_representation), validate_certs=self.validate_certs) except Exception as e: @@ -976,7 +976,7 @@ def delete_user(self, id, realm="master"): user_url = URL_USER.format(url=self.baseurl, realm=realm, id=id) try: - return open_url(user_url, method='DELETE', headers=self.restheaders.header, + return open_url(user_url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete user %s in realm %s: %s' @@ -984,7 +984,7 @@ def delete_user(self, id, realm="master"): def get_json_from_url(self, url): try: - user_json = json.load(open_url(url, method='GET', headers=self.restheaders.header, + user_json = json.load(open_url(url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) return user_json except ValueError as e: @@ -1017,7 +1017,7 @@ def get_role(self, role_id, realm='master', client_uuid=None): try: return json.load( - open_url(role_url, method='GET', headers=self.restheaders.header, + open_url(role_url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) except HTTPError as e: if e.code == 404: @@ -1044,7 +1044,7 @@ def delete_role(self, role_id, realm="master"): role_url = URL_REALM_ROLE_BY_ID.format(url=self.baseurl, realm=quote(realm), id=quote(role_id)) try: - return open_url(role_url, method='DELETE', headers=self.restheaders.header, + return open_url(role_url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not delete role %s in realm %s: %s' @@ -1076,7 +1076,7 @@ def create_role(self, role_representation, realm="master", client_uuid=None): role_url = URL_REALM_ROLES.format(url=self.baseurl, realm=quote(realm)) try: - return open_url(role_url, method='POST', headers=self.restheaders.header, + return open_url(role_url, method='POST', headers=self.restheaders, data=json.dumps(role_representation), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json( @@ -1094,7 +1094,7 @@ def update_role(self, role_id, role_representation, realm="master", client_uuid= role_url = self.get_role_url(role_id, realm, client_uuid) try: - return open_url(role_url, method='PUT', headers=self.restheaders.header, + return open_url(role_url, method='PUT', headers=self.restheaders, data=json.dumps(role_representation), validate_certs=self.validate_certs) except Exception as e: @@ -1115,7 +1115,7 @@ def get_groups(self, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" @@ -1132,7 +1132,7 @@ def get_group_by_groupid(self, gid, realm="master"): """ groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders.header, + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, validate_certs=self.validate_certs)) except HTTPError as e: @@ -1178,7 +1178,7 @@ def get_realm_roles_of_group(self, group_uuid, realm='master'): ) try: return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders.header, + headers=self.restheaders, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json( @@ -1193,7 +1193,7 @@ def get_client_roles_of_group(self, group_uuid, client_uuid, realm='master'): ) try: return json.load(open_url(url=effective_role_url, method="GET", - headers=self.restheaders.header, + headers=self.restheaders, validate_certs=self.validate_certs)) except Exception as e: self.module.fail_json( @@ -1208,7 +1208,7 @@ def create_group(self, grouprep, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return open_url(groups_url, method='POST', headers=self.restheaders.header, + return open_url(groups_url, method='POST', headers=self.restheaders, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg="Could not create group %s in realm %s: %s" @@ -1223,7 +1223,7 @@ def update_group(self, grouprep, realm="master"): group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) try: - return open_url(group_url, method='PUT', headers=self.restheaders.header, + return open_url(group_url, method='PUT', headers=self.restheaders, data=json.dumps(grouprep), validate_certs=self.validate_certs) except Exception as e: self.module.fail_json(msg='Could not update group %s in realm %s: %s' @@ -1260,7 +1260,7 @@ def delete_group(self, name=None, groupid=None, realm="master"): # should have a good groupid by here. group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) try: - return open_url(group_url, method='DELETE', headers=self.restheaders.header, + return open_url(group_url, method='DELETE', headers=self.restheaders, validate_certs=self.validate_certs) except Exception as e: From 0b5d49bc64a7bbf8aff711e8b436ca5cf79d2c56 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 9 Sep 2019 11:38:47 +0200 Subject: [PATCH 65/79] refactor: use the get_token function --- .../identity/keycloak/keycloak_client.py | 23 +++++++------ .../keycloak/keycloak_client_scope.py | 24 +++++++------- .../keycloak/keycloak_clienttemplate.py | 23 +++++++------ .../identity/keycloak/keycloak_group.py | 23 +++++++------ .../keycloak/keycloak_group_role_mapping.py | 23 +++++++------ .../keycloak/keycloak_ldap_federation.py | 32 +++++++++++-------- .../keycloak/keycloak_ldap_synchronization.py | 24 ++++++++------ .../keycloak/keycloak_link_user_to_group.py | 24 ++++++++------ .../identity/keycloak/keycloak_realm.py | 23 +++++++------ .../identity/keycloak/keycloak_role.py | 23 +++++++------ .../identity/keycloak/keycloak_user.py | 23 +++++++------ 11 files changed, 150 insertions(+), 115 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py index e0d5f9f6d64d32..dba8a9d3c4c7bd 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client.py @@ -629,7 +629,7 @@ ''' from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, KeycloakAuthorizationHeader + keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -715,15 +715,18 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py index a31e74aa0ab525..2a4adb7d01f320 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py @@ -279,7 +279,7 @@ from copy import deepcopy from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.identity.keycloak.keycloak import( - KeycloakAPI, keycloak_argument_spec, check_role_representation, KeycloakAuthorizationHeader + KeycloakAPI, keycloak_argument_spec, check_role_representation, get_token, KeycloakError ) @@ -329,16 +329,18 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - # Obtain access token, initialize API - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) # Initialize some general variables diff --git a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py index 8cc8f85d43f55c..657acd965850b2 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py @@ -247,7 +247,7 @@ ''' from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, KeycloakAuthorizationHeader + keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -290,15 +290,18 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group.py b/lib/ansible/modules/identity/keycloak/keycloak_group.py index bbc87bdb7219f2..2a0b4725d2e396 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group.py @@ -208,7 +208,7 @@ ''' from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ - keycloak_argument_spec, KeycloakAuthorizationHeader + keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -236,15 +236,18 @@ def main(): result = dict(changed=False, msg='', diff={}, group='') # Obtain access token, initialize API - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py index 90b7ce99c6c7f3..89eaaea037de7f 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py @@ -146,7 +146,7 @@ from ansible.module_utils._text import to_text from ansible.module_utils.identity.keycloak.keycloak import ( - KeycloakAuthorizationHeader, KeycloakAPI, keycloak_argument_spec + get_token, KeycloakAPI, keycloak_argument_spec, KeycloakError ) @@ -184,15 +184,18 @@ def run_module(): realm = module.params.get('realm') state = module.params.get('state') result = {} - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) given_role_id = {'name': module.params.get('role_name')} diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 833e02139ce89d..5305d12496d5d9 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -564,7 +564,8 @@ from ansible.module_utils.identity.keycloak.keycloak import ( camel, keycloak_argument_spec, - KeycloakAuthorizationHeader, + get_token, + KeycloakError, ) from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import LdapFederationBase from ansible.module_utils.common.dict_transformations import dict_merge @@ -595,7 +596,7 @@ def delete(self): open_url( federation_url, method='DELETE', - headers=self.restheaders.header, + headers=self.restheaders, validate_certs=self.module.params.get('validate_certs'), ) except Exception as e: @@ -625,7 +626,7 @@ def update(self): open_url( put_url, method='PUT', - headers=self.restheaders.header, + headers=self.restheaders, validate_certs=self.module.params.get('validate_certs'), data=json.dumps(federation_payload), ) @@ -662,7 +663,7 @@ def create(self): open_url( post_url, method='POST', - headers=self.restheaders.header, + headers=self.restheaders, validate_certs=self.module.params.get('validate_certs'), data=json.dumps(federation_payload), ) @@ -742,7 +743,7 @@ def _call_test_url(self, extra_arguments): url=self.module.params.get('auth_keycloak_url'), realm=self.module.params.get('realm'), ) - headers = deepcopy(self.restheaders.header) + headers = deepcopy(self.restheaders) headers.update( {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} ) @@ -1009,15 +1010,18 @@ def run_module(): ['test_connection', 'test_authentication'], ], ) - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) ldap_federation = LdapFederation(module, connection_header) waited_state = module.params.get('state') result = {} diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py index af9a8c27271a88..3ad6097b68c322 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -137,8 +137,9 @@ ) from ansible.module_utils.identity.keycloak.keycloak import ( keycloak_argument_spec, - KeycloakAuthorizationHeader, + get_token, post_on_url, + KeycloakError, ) ALL_OPERATIONS = ['synchronize_changed_users', 'synchronize_all_users', 'remove_imported', 'unlink_users'] @@ -242,15 +243,18 @@ def run_module(): ALL_OPERATIONS, ], ) - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) ldap_synchronization = LdapSynchronization(module, connection_header) ldap_synchronization.synchronize() result = { diff --git a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py index f08cedf574b709..2e45cad8f6a195 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -132,10 +132,11 @@ from ansible.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, keycloak_argument_spec, - KeycloakAuthorizationHeader, + get_token, get_on_url, put_on_url, delete_on_url, + KeycloakError, ) from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_text @@ -294,15 +295,18 @@ def run_module(): ['group_name', 'group_id'], ], ) - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) link_user_to_group = KeycloakLinkUserToGroup(module, connection_header) state = module.params.get('state') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_realm.py b/lib/ansible/modules/identity/keycloak/keycloak_realm.py index 3fe5ed72ae0931..fbd07552dbbf61 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_realm.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_realm.py @@ -834,7 +834,7 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.identity.keycloak.keycloak import ( - KeycloakAuthorizationHeader, KeycloakAPI, camel, keycloak_argument_spec + get_token, KeycloakAPI, camel, keycloak_argument_spec, KeycloakError ) @@ -946,15 +946,18 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) state = module.params.get('state') diff --git a/lib/ansible/modules/identity/keycloak/keycloak_role.py b/lib/ansible/modules/identity/keycloak/keycloak_role.py index 8581a4868c57fe..f177749a56c3af 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_role.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -177,7 +177,7 @@ from ansible.module_utils._text import to_text from ansible.module_utils.identity.keycloak.keycloak import ( - KeycloakAPI, camel, keycloak_argument_spec, KeycloakAuthorizationHeader, + KeycloakAPI, camel, keycloak_argument_spec, get_token, KeycloakError, ) from ansible.module_utils.basic import AnsibleModule @@ -222,15 +222,18 @@ def run_module(): 'Attributes are not in the correct format. Should be a dictionary with ' 'one value per key as string, integer and boolean')) - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) before_role, client_uuid = get_initial_role(given_role_id, kc, realm, client_id) result = create_result(before_role, module) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py index 6d1220c0949d0e..ef2e4e81b7ff40 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_user.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -199,7 +199,7 @@ ''' from ansible.module_utils.identity.keycloak.keycloak import ( - KeycloakAPI, camel, keycloak_argument_spec, KeycloakAuthorizationHeader + KeycloakAPI, camel, keycloak_argument_spec, get_token, KeycloakError ) from ansible.module_utils.basic import AnsibleModule @@ -267,15 +267,18 @@ def run_module(): 'Attributes are not in the correct format. Should be a dictionary with ' 'one value per key as string, integer and boolean')) - connection_header = KeycloakAuthorizationHeader( - base_url=module.params.get('auth_keycloak_url'), - validate_certs=module.params.get('validate_certs'), - auth_realm=module.params.get('auth_realm'), - client_id=module.params.get('auth_client_id'), - auth_username=module.params.get('auth_username'), - auth_password=module.params.get('auth_password'), - client_secret=module.params.get('auth_client_secret'), - ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as e: + module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) before_user = get_initial_user(given_user_id, kc, realm) From f1d3f43f7da6a5d34f59045f8ae69ee1d61f984c Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 9 Sep 2019 13:21:38 +0200 Subject: [PATCH 66/79] dev: delete duplicated lines --- lib/ansible/module_utils/identity/keycloak/keycloak.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index a94ebfd72d9c4c..7d35d420b9f679 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -62,9 +62,6 @@ URL_USERS = "{url}/admin/realms/{realm}/users" URL_USER = "{url}/admin/realms/{realm}/users/{id}" -URL_USERS = "{url}/admin/realms/{realm}/users" -URL_USER = "{url}/admin/realms/{realm}/users/{id}" - URL_CLIENT_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings" URL_CLIENT_SCOPE_MAPPINGS_CLIENT = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/clients/{client}" URL_CLIENT_SCOPE_MAPPINGS_CLIENT_AVAILABLE = "{url}/admin/realms/{realm}/{target}s/{id}/scope-mappings/clients/{client}/available" From 24ea1ded538930ceb4b3fd26b7205b1d8ea99043 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 9 Sep 2019 13:40:20 +0200 Subject: [PATCH 67/79] refactor: again delete duplicated functions --- .../identity/keycloak/keycloak.py | 72 ------------------- 1 file changed, 72 deletions(-) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 7d35d420b9f679..f40e1c15b1877f 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -72,78 +72,6 @@ URL_REALMS = "{url}/admin/realms" -def get_on_url(url, restheaders, module, description): - """Get a keycloak url - - :param url: the url to get - :param restheaders: the keycloak restheader with the token - :param module: the module calling this function - :param description: the get object description put in the error message - if the open_url fails - :return: the read json from the url - """ - validate_certs = module.params.get('validate_certs') - realm = module.params.get('realm') - try: - return json.load(open_url(url, method='GET', - headers= restheaders, - validate_certs=validate_certs)) - except ValueError as e: - module.fail_json( - msg='API returned incorrect JSON when trying to obtain %s for realm %s: %s' - % (description, realm, str(e))) - except Exception as e: - module.fail_json( - msg='Could not obtain %s for realm %s: %s' % (description, realm, str(e))) - - -def put_on_url(url, restheaders, module, description, representation=None): - """Put on a keycloak url - - :param url: the url to put - :param restheaders: the keycloak restheader with the token - :param module: the module calling this function - :param description: the put object description put in the error message - if the open_url fails - :param representation: the object as a dictionary to put on keycloak - """ - validate_certs = module.params.get('validate_certs') - realm = module.params.get('realm') - if not representation: - pushed_data = json.dumps(representation) - else: - pushed_data = {} - try: - return open_url(url, method='PUT', - headers= restheaders, - data=pushed_data, - validate_certs=validate_certs) - except Exception as e: - module.fail_json( - msg="Could not modified %s in realm %s: %s" % (description, realm, str(e))) - - -def delete_on_url(url, restheaders, module, description): - """Delete a keycloak url - - :param url: the url to delete - :param restheaders: the keycloak restheader with the token - :param module: the module calling this function - :param description: the deleted object description put in the error message - if the open_url fails - :return: the read json from the url - """ - validate_certs = module.params.get('validate_certs') - realm = module.params.get('realm') - try: - return open_url(url, method='DELETE', - headers= restheaders, - validate_certs=validate_certs) - except Exception as e: - module.fail_json( - msg="Could not delete %s in realm %s: %s" % (description, realm, str(e))) - - def get_on_url(url, restheaders, module, description): """Get a keycloak url :param url: the url to get From 73023d95643b04f45318de3a11a8d4b77573ecc1 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Tue, 10 Sep 2019 12:27:48 +0200 Subject: [PATCH 68/79] refactor: use the generic function created for synchronization ldap --- .../identity/keycloak/keycloak.py | 6 +- .../keycloak/keycloak_ldap_federation.py | 81 ++++----- .../keycloak/keycloak_ldap_group_mapper.py | 30 ++++ .../test_kecyloak_ldap_group_mapper.py | 0 .../keycloak/test_keycloak_ldap_federation.py | 170 ++++++++++-------- 5 files changed, 159 insertions(+), 128 deletions(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py create mode 100644 test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index f40e1c15b1877f..86e9cbb09038af 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -144,10 +144,10 @@ def call_with_payload_on_url(url, restheaders, module, description, method, repr validate_certs = module.params.get('validate_certs') realm = module.params.get('realm') method_verb = { - 'PUT': 'modified', - 'POST': 'created' + 'PUT': 'modify', + 'POST': 'create' } - if not representation: + if representation: pushed_data = json.dumps(representation) else: pushed_data = {} diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 5305d12496d5d9..3d3fd05cb51878 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -566,9 +566,9 @@ keycloak_argument_spec, get_token, KeycloakError, -) + delete_on_url, post_on_url, put_on_url) from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import LdapFederationBase -from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.common.dict_transformations import dict_merge, recursive_diff from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import quote, urlencode @@ -592,26 +592,19 @@ def __init__(self, module, connection_header): def delete(self): """Delete the federation""" federation_url = self._get_federation_url() - try: - open_url( - federation_url, - method='DELETE', - headers=self.restheaders, - validate_certs=self.module.params.get('validate_certs'), - ) - except Exception as e: - self.module.fail_json( - msg='Could not delete federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) - ) + delete_on_url(federation_url, self.restheaders, self.module, 'federation %s' % self.given_id) - def update(self): + def update(self, check=False): """Update the federation :return: the representation of the updated federation :rtype: dict """ + if not self._arguments_update_representation(): + return {} federation_payload = self._create_payload() + if check: + return self._clean_payload(federation_payload) put_url = USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), @@ -622,21 +615,18 @@ def update(self): if self.module.params.get('test_authentication'): self._test_connection() self._test_authentication() - try: - open_url( - put_url, - method='PUT', - headers=self.restheaders, - validate_certs=self.module.params.get('validate_certs'), - data=json.dumps(federation_payload), - ) - except Exception as e: - self.module.fail_json( - msg='Could not update federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) - ) + put_on_url(put_url, self.restheaders, self.module, 'federation %s' % self.given_id, federation_payload) return self._clean_payload(federation_payload) + def _arguments_update_representation(self): + clean_payload = self._clean_payload(self._create_payload(), credential_clean=False) + payload_diff, _ = recursive_diff(clean_payload, self.federation) + payload_diff.pop('providerId') + payload_diff.pop('providerType') + if not payload_diff: + return False + return True + def create(self): """Create the federation from the given arguments. @@ -659,19 +649,7 @@ def create(self): if self.module.params.get('test_authentication'): self._test_connection() self._test_authentication() - try: - open_url( - post_url, - method='POST', - headers=self.restheaders, - validate_certs=self.module.params.get('validate_certs'), - data=json.dumps(federation_payload), - ) - except Exception as e: - self.module.fail_json( - msg='Could not create federation %s in realm %s: %s' - % (self.given_id, self.module.params.get('realm'), str(e)) - ) + post_on_url(post_url, self.restheaders, self.module, 'federation %s' % self.given_id, federation_payload) return self._clean_payload(federation_payload) def _test_connection(self): @@ -1061,13 +1039,22 @@ def run_module(): if not module.check_mode: payload = ldap_federation.update() else: - payload = ldap_federation.get_result() - result['msg'] = to_text( - 'Federation {given_id} updated.'.format( - given_id=ldap_federation.given_id + payload = ldap_federation.update(check=True) + if payload: + result['msg'] = to_text( + 'Federation {given_id} updated.'.format( + given_id=ldap_federation.given_id + ) ) - ) - result['changed'] = True + result['changed'] = True + else: + result['msg'] = to_text( + 'Federation {given_id} up to date, doing nothing.'.format( + given_id=ldap_federation.given_id + ) + ) + result['changed'] = False + result['ldap_federation'] = payload module.exit_json(**result) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py new file mode 100644 index 00000000000000..287f8af93ece4b --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -0,0 +1,30 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = r''' +''' + +EXAMPLES = r''' +''' + +RETURN = r''' +''' + +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + get_token, + post_on_url, + KeycloakError, +) \ No newline at end of file diff --git a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 818375b0a090dd..9684e2703109a0 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + import json from copy import deepcopy from itertools import count, filterfalse @@ -143,10 +147,13 @@ def test_state_absent_should_not_create_absent_federation( assert not ansible_exit_json['ldap_federation'] -@pytest.fixture() -def mock_get_for_delete(mocker): - get_for_delete = deepcopy(CONNECTION_DICT) - get_for_delete.update({ +@pytest.fixture +def mock_delete_url(mocker): + # This fixture does not return a full federation json, just an extract + # with parts needed in the test and some value in order to have object + # organisation. + delete_federation = deepcopy(CONNECTION_DICT) + delete_federation.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=ldap-to-delete': create_wrapper( json.dumps( [ @@ -159,36 +166,22 @@ def mock_get_for_delete(mocker): ] ) ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( - json.dumps( - { - 'id': '123-123', - 'name': 'ldap-to-delete', - 'parentId': 'master', - 'config': {'pagination': [True]}, - } - ) - ), - }) - return mocker.patch( - 'ansible.module_utils.identity.keycloak.keycloak.open_url', - side_effect=build_mocked_request(count(), get_for_delete), - autospec=True, - ) - - -@pytest.fixture -def mock_delete_url(mocker): - # This fixture does not return a full federation json, just an extract - # with parts needed in the test and some value in order to have object - # organisation. - delete_federation = { 'http://keycloak.url/auth/admin/realms/master/components/123-123': { 'DELETE': None, + 'GET': create_wrapper( + json.dumps( + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), }, - } + }) return mocker.patch( - 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', + 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), delete_federation), autospec=True, ) @@ -199,7 +192,7 @@ def mock_delete_url(mocker): [{'federation_id': 'ldap-to-delete'}, {'federation_uuid': '123-123'}], ) def test_state_absent_should_delete_existing_federation( - monkeypatch, extra_arguments, mock_delete_url, mock_get_for_delete + monkeypatch, extra_arguments, mock_delete_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -226,33 +219,22 @@ def test_state_absent_should_delete_existing_federation( @pytest.fixture() def mock_create_url(mocker): - create_federation = { - 'http://keycloak.url/auth/admin/realms/master/components/': None, - } - return mocker.patch( - 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', - side_effect=build_mocked_request(count(), create_federation), - autospec=True, - ) - - -@pytest.fixture() -def mock_get_for_create(mocker): - get_federation = deepcopy(CONNECTION_DICT) - get_federation.update({ + create_federation = deepcopy(CONNECTION_DICT) + create_federation.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( json.dumps([]) ), + 'http://keycloak.url/auth/admin/realms/master/components/': None, }) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', - side_effect=build_mocked_request(count(), get_federation), + side_effect=build_mocked_request(count(), create_federation), autospec=True, ) def test_state_present_should_create_absent_federation( - monkeypatch, mock_create_url, mock_get_for_create + monkeypatch, mock_create_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -324,7 +306,7 @@ def test_state_present_should_create_absent_federation( else: send_config.update({key: [value]}) reference_result.update({'config': send_config}) - create_call = mock_create_url.mock_calls[0] + create_call = mock_create_url.mock_calls[2] send_json = json.loads(create_call[2]['data']) diff_result = recursive_diff(send_json, reference_result) assert not diff_result @@ -354,14 +336,13 @@ def test_create_payload_all_mandatory(monkeypatch, mock_absent_url): @pytest.fixture() -def mock_create_url_with_check(mocker): - create_federation = { - 'http://keycloak.url/auth/admin/realms/master/components/': None, +def mock_check_connectivity(mocker): + check_connectivity = { 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, } return mocker.patch( 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', - side_effect=build_mocked_request(count(), create_federation), + side_effect=build_mocked_request(count(), check_connectivity), autospec=True, ) @@ -372,7 +353,7 @@ def mock_create_url_with_check(mocker): ids=['connection only', 'authentication'], ) def test_arguments_check_connectivity_should_try_ldap_connection( - monkeypatch, extra_arguments, mock_create_url_with_check, mock_get_for_create + monkeypatch, extra_arguments, mock_create_url, mock_check_connectivity ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -403,7 +384,7 @@ def test_arguments_check_connectivity_should_try_ldap_connection( ansible_exit_json = exec_error.value.args[0] assert ansible_exit_json['msg'] == 'Federation company-ldap created.' assert ansible_exit_json['changed'] - calls = mock_create_url_with_check.mock_calls + calls = mock_check_connectivity.mock_calls for one_call in filterfalse(lambda x: 'testLDAPConnection' not in x.args[0], calls): send_data = one_call.kwargs['data'] assert urlencode({'bindCredential': 'ldap_admin_password'}) in send_data @@ -419,6 +400,12 @@ def _raise_400(): @pytest.fixture() def mock_wrong_authentication_url(mocker, request): + create_federation = deepcopy(CONNECTION_DICT) + create_federation.update({ + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ), + }) ldap_connection = { 'wrong LDAP address': raise_400( 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection' @@ -430,14 +417,20 @@ def mock_wrong_authentication_url(mocker, request): ), ], } - create_federation = { + check_connection = { + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': ldap_connection[ request.node.callspec.id ], } + mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), create_federation), + autospec=True, + ) return mocker.patch( 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', - side_effect=build_mocked_request(count(), create_federation), + side_effect=build_mocked_request(count(), check_connection), autospec=True, ) @@ -472,7 +465,6 @@ def test_wrong_ldap_credentials_should_raise_an_error( extra_arguments, waited_message, mock_wrong_authentication_url, - mock_get_for_create, ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -502,21 +494,8 @@ def test_wrong_ldap_credentials_should_raise_an_error( @pytest.fixture() def mock_update_url(mocker): - update_federation = { - 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, - } - return mocker.patch( - 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', - side_effect=build_mocked_request(count(), update_federation), - autospec=True, - ) - - -@pytest.fixture() -def mock_get_for_update(mocker): - get_federation = deepcopy(CONNECTION_DICT) - get_federation.update({ + update_federation = deepcopy(CONNECTION_DICT) + update_federation.update({ 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( json.dumps( [ @@ -535,24 +514,28 @@ def mock_get_for_update(mocker): 'evictionMinute': [], 'maxLifespan': [], 'customUserSearchFilter': [], + 'connectionPooling': [False], 'syncRegistrations': [False], 'priority': [3], 'cachePolicy': ['DEFAULT'], + 'uuidLDAPAttribute': ['entryUUID'], }, } ] ) ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, }) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', - side_effect=build_mocked_request(count(), get_federation), + side_effect=build_mocked_request(count(), update_federation), autospec=True, ) def test_state_present_should_update_existing_federation( - monkeypatch, mock_get_for_update, mock_update_url + monkeypatch, mock_update_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -598,8 +581,29 @@ def test_state_present_should_update_existing_federation( assert not diff_result +def test_same_values_should_not_update_an_existing_federation( + monkeypatch, mock_update_url +): + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'state': 'present', + 'federation_id': 'company-ldap', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_federation.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Federation company-ldap up to date, doing nothing.' + + def test_state_present_should_update_existing_federation_with_connect_check( - monkeypatch, mock_get_for_update, mock_update_url + monkeypatch, mock_update_url, mock_check_connectivity ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -625,7 +629,9 @@ def test_state_present_should_update_existing_federation_with_connect_check( assert urlencode({'bindDn': 'cn:admin'}) in send_data -def test_create_payload_for_synchronization(monkeypatch, mock_get_for_update, mock_update_url): +def test_create_payload_for_synchronization( + monkeypatch, mock_update_url, mock_check_connectivity +): """When updating the sync registration of a federation, the payload needs to have some keys. If not, the response is 204 put the sync registration parameter is not updated.""" @@ -645,12 +651,20 @@ def test_create_payload_for_synchronization(monkeypatch, mock_get_for_update, mo set_module_args(arguments) with pytest.raises(AnsibleExitJson): keycloak_ldap_federation.run_module() - put_call = mock_update_url.mock_calls[0] + put_call = mock_update_url.mock_calls[2] pushed_data = json.loads(put_call[2]['data']) config = pushed_data.pop('config') all_config_keys = list(config.keys()) mandatory_keys_for_sync = [ - 'batchSizeForSync', 'fullSyncPeriod', 'changedSyncPeriod', 'evictionDay', 'evictionHour', 'evictionMinute', 'maxLifespan', 'customUserSearchFilter'] + 'batchSizeForSync', + 'fullSyncPeriod', + 'changedSyncPeriod', + 'evictionDay', + 'evictionHour', + 'evictionMinute', + 'maxLifespan', + 'customUserSearchFilter', + ] for one_key in mandatory_keys_for_sync: assert one_key in all_config_keys assert config['syncRegistrations'] == [True] From eadb550bce013a644cb24b7986492a1718fac44a Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Tue, 10 Sep 2019 15:52:36 +0200 Subject: [PATCH 69/79] delete unwanted file --- .../keycloak/keycloak_ldap_group_mapper.py | 30 ------------------- .../test_kecyloak_ldap_group_mapper.py | 0 2 files changed, 30 deletions(-) delete mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py delete mode 100644 test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py deleted file mode 100644 index 287f8af93ece4b..00000000000000 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/python -# -*- coding: utf-8 -*- - -# Copyright: (c) 2018, Nicolas Duclert -# 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 - -ANSIBLE_METADATA = { - 'metadata_version': '1.1', - 'status': ['preview'], - 'supported_by': 'community', -} - -DOCUMENTATION = r''' -''' - -EXAMPLES = r''' -''' - -RETURN = r''' -''' - -from ansible.module_utils.identity.keycloak.keycloak import ( - keycloak_argument_spec, - get_token, - post_on_url, - KeycloakError, -) \ No newline at end of file diff --git a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py deleted file mode 100644 index e69de29bb2d1d6..00000000000000 From 704b65e86ffcacd6d9e0e81f444b2cf5a9b3ce05 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Thu, 12 Sep 2019 09:26:07 +0200 Subject: [PATCH 70/79] test: better json response --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 2 -- .../modules/identity/keycloak/test_keycloak_ldap_federation.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 3d3fd05cb51878..b7b909d0200f6a 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -621,8 +621,6 @@ def update(self, check=False): def _arguments_update_representation(self): clean_payload = self._clean_payload(self._create_payload(), credential_clean=False) payload_diff, _ = recursive_diff(clean_payload, self.federation) - payload_diff.pop('providerId') - payload_diff.pop('providerType') if not payload_diff: return False return True diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 9684e2703109a0..f06798c1503bbb 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -503,6 +503,8 @@ def mock_update_url(mocker): 'id': '123-123', 'name': 'company-ldap', 'parentId': 'master', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', 'config': { 'pagination': [True], 'bindDn': ['cn:admin'], From 29a5f905a13ddcba0fce7a5f1dc6ee94fdf18794 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Wed, 26 Jun 2019 11:41:28 +0200 Subject: [PATCH 71/79] feature: add a synchronization between keycloak and an existing federation --- ...ycloak-add-ldap-synchronisation-module.yml | 2 + .../keycloak/keycloak_ldap_federation.py | 55 +++++++++++----- .../keycloak/keycloak_ldap_synchronization.py | 16 ++++- .../keycloak/test_keycloak_ldap_federation.py | 2 - .../test_keycloak_ldap_synchronisation.py | 65 ++++++------------- 5 files changed, 74 insertions(+), 66 deletions(-) create mode 100644 changelogs/fragments/keycloak-add-ldap-synchronisation-module.yml diff --git a/changelogs/fragments/keycloak-add-ldap-synchronisation-module.yml b/changelogs/fragments/keycloak-add-ldap-synchronisation-module.yml new file mode 100644 index 00000000000000..78e44c435330d3 --- /dev/null +++ b/changelogs/fragments/keycloak-add-ldap-synchronisation-module.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_ldap_synchronisation - add a new module synchronizing the content of the LDAP server with keycloak \ No newline at end of file diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index b7b909d0200f6a..5ce8de801cded6 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -20,14 +20,14 @@ short_description: Allows administration of Keycloak LDAP federation via Keycloak API description: - - This module allows you to add, remove or modify Keycloak LDAP federation via the Keycloak API. + - This module allows you to add, remove or modify Keycloak LDAP federation via the Keycloak 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(http://www.keycloak.org/docs-api/3.3/rest-api/). + Keycloak API and its documentation at U(http://www.keycloak.org/docs-api/6.0/rest-api/). - At creation and update, this module allows you to test the connection or the authentication to the LDAP service from the given arguments. If the connection or the authentication does @@ -36,6 +36,8 @@ - When updating a LDAP federation, where possible provide the group ID to the module. This removes a lookup to the API to translate the name into the group ID. + - This module has been tested against keycloak 6.0, the backward compatibility is not garanteed. + version_added: "2.9" options: @@ -60,7 +62,7 @@ federation_id: description: - The name of the federation - - Also called ID of the federationin the table of federations or + - Also called ID of the federation in the table of federations or the console display name in the detailed view of a federation - This parameter is mutually exclusive with federation_uuid and one of them is required by the module @@ -254,6 +256,8 @@ this module, I(importUser), I(connectionPooling) (and all associated parameters), I(allowKerberosAuthentication) (and all associated parameters), I(useKerberosForPasswordAuthentication), I(batchSize) and I(cachePolicy). + - The created federation will always be enabled. Adding this parameter in the payload bring + keycloak about making weird things (some parameters are not updated anymore). extends_documentation_fragment: - keycloak @@ -558,7 +562,6 @@ sample: FOO.ORG ''' -import json from copy import deepcopy from ansible.module_utils._text import to_text from ansible.module_utils.identity.keycloak.keycloak import ( @@ -566,8 +569,13 @@ keycloak_argument_spec, get_token, KeycloakError, - delete_on_url, post_on_url, put_on_url) -from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import LdapFederationBase + delete_on_url, + post_on_url, + put_on_url, +) +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( + LdapFederationBase, +) from ansible.module_utils.common.dict_transformations import dict_merge, recursive_diff from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import open_url @@ -592,7 +600,12 @@ def __init__(self, module, connection_header): def delete(self): """Delete the federation""" federation_url = self._get_federation_url() - delete_on_url(federation_url, self.restheaders, self.module, 'federation %s' % self.given_id) + delete_on_url( + federation_url, + self.restheaders, + self.module, + 'federation %s' % self.given_id, + ) def update(self, check=False): """Update the federation @@ -615,11 +628,19 @@ def update(self, check=False): if self.module.params.get('test_authentication'): self._test_connection() self._test_authentication() - put_on_url(put_url, self.restheaders, self.module, 'federation %s' % self.given_id, federation_payload) + put_on_url( + put_url, + self.restheaders, + self.module, + 'federation %s' % self.given_id, + federation_payload, + ) return self._clean_payload(federation_payload) def _arguments_update_representation(self): - clean_payload = self._clean_payload(self._create_payload(), credential_clean=False) + clean_payload = self._clean_payload( + self._create_payload(), credential_clean=False + ) payload_diff, _ = recursive_diff(clean_payload, self.federation) if not payload_diff: return False @@ -647,7 +668,13 @@ def create(self): if self.module.params.get('test_authentication'): self._test_connection() self._test_authentication() - post_on_url(post_url, self.restheaders, self.module, 'federation %s' % self.given_id, federation_payload) + post_on_url( + post_url, + self.restheaders, + self.module, + 'federation %s' % self.given_id, + federation_payload, + ) return self._clean_payload(federation_payload) def _test_connection(self): @@ -774,7 +801,7 @@ def _create_payload(self): not_federation_argument = list(keycloak_argument_spec().keys()) + [ 'state', 'realm', - 'test_authentication' + 'test_authentication', ] for key, value in self.module.params.items(): if value is not None and key not in not_federation_argument: @@ -789,9 +816,7 @@ def _create_payload(self): value.sort() config.update({camel(key): [', '.join(value)]}) else: - config.update( - {camel(key).replace('Ldap', 'LDAP'): [value]} - ) + config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) try: old_configuration = { key: [value] for key, value in self.federation['config'].items() @@ -809,7 +834,7 @@ def _create_payload(self): 'evictionHour': None, 'evictionMinute': None, 'maxLifespan': None, - 'batchSizeForSync': 1000 + 'batchSizeForSync': 1000, } payload.update( { diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py index 3ad6097b68c322..8ea6ae3a33a572 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -28,7 +28,7 @@ - The names of module options are snake_cased versions of the camelCase ones found in the Keycloak API and its documentation at U(http://www.keycloak.org/docs-api/4.8/rest-api/). -version_added: "2.9" +version_added: "2.10" options: realm: @@ -146,6 +146,8 @@ class LdapSynchronization(LdapFederationBase): + """This class manage the LDAP federation synchronization. + """ def __init__(self, module, connection_header): super(LdapSynchronization, self).__init__(module, connection_header) self.message = '' @@ -160,6 +162,8 @@ def __init__(self, module, connection_header): ) def synchronize(self): + """Do the asked synchronisation operation. + """ synchronize_url = self._build_url() response = post_on_url(synchronize_url, self.restheaders, self.module, '{} for {}'.format(self.operation, self.given_id)) try: @@ -185,12 +189,17 @@ def _build_url(self): @property def operation(self): + """Get the operation name given as module parameter. + + :return: the user given operation name. + """ for one_name in ALL_OPERATIONS: potential_operation = self.module.params.get(one_name) if potential_operation: return one_name def _create_message(self, synchronisation_result): + """Create the message for the result or fail json.""" # only the synchronization return a dictionary if synchronisation_result: self.message = synchronisation_result['status'] + '.' @@ -203,6 +212,7 @@ def _create_message(self, synchronisation_result): raise ValueError('The operation does not have a message.') def _udpate_changed(self, synchronisation_result): + """With the returned informations, try to know if the keycloak is changed.""" # if the post does not return a response, it is a call to unlink or # remove. The changed status have to stay to True.. if synchronisation_result: @@ -253,8 +263,8 @@ def run_module(): auth_password=module.params.get('auth_password'), client_secret=module.params.get('auth_client_secret'), ) - except KeycloakError as e: - module.fail_json(msg=str(e)) + except KeycloakError as err: + module.fail_json(err) ldap_synchronization = LdapSynchronization(module, connection_header) ldap_synchronization.synchronize() result = { diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index f06798c1503bbb..08b1a59352c83e 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -1,7 +1,5 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function - - __metaclass__ = type diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py index b82d0167b23e24..c728339f00d860 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function +__metaclass__ = type import json from copy import deepcopy from itertools import count +import pytest from ansible.module_utils._text import to_text +from ansible.module_utils.six import StringIO +from ansible.modules.identity.keycloak import keycloak_ldap_synchronization from units.modules.utils import ( AnsibleExitJson, AnsibleFailJson, @@ -13,11 +17,6 @@ exit_json, set_module_args, ) -from ansible.modules.identity.keycloak import keycloak_ldap_synchronization - -import pytest - -from ansible.module_utils.six import StringIO def create_wrapper(text_as_string): @@ -52,30 +51,17 @@ 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 - ) + return get_response(object_with_future_response[method], method, get_id_call_count) if isinstance(object_with_future_response, list): try: call_number = get_id_call_count.__next__() except AttributeError: # manage python 2 versions. call_number = get_id_call_count.next() - return get_response( - object_with_future_response[call_number], method, get_id_call_count - ) + return get_response(object_with_future_response[call_number], method, get_id_call_count) return object_with_future_response -@pytest.fixture -def mock_get_token(mocker): - return mocker.patch( - 'ansible.module_utils.identity.keycloak.keycloak.open_url', - side_effect=build_mocked_request(count(), CONNECTION_DICT), - autospec=True, - ) - - @pytest.fixture def mock_absent_url(mocker): absent_federation = deepcopy(CONNECTION_DICT) @@ -94,12 +80,8 @@ def mock_absent_url(mocker): def test_federation_does_not_exist_fail(monkeypatch, mock_absent_url): - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json - ) - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json - ) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json) arguments = { 'auth_keycloak_url': 'http://keycloak.url/auth', 'auth_username': 'test_admin', @@ -160,8 +142,12 @@ def mock_synchronisation_url(mocker): } ) ), - 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/remove-imported-users': create_wrapper(''), - 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': create_wrapper(''), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/remove-imported-users': create_wrapper( + '' + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': create_wrapper( + '' + ), } ) return mocker.patch( @@ -191,22 +177,13 @@ def mock_synchronisation_url(mocker): ), ({'unlink_users': True}, 'Unlink users of company-ldap.', 'unlink-users'), ], - ids=[ - 'Synchronize all users', - 'Synchronize changed users', - 'Remove users', - 'Unlink users', - ], + ids=['Synchronize all users', 'Synchronize changed users', 'Remove users', 'Unlink users'], ) def test_synchronize_change_user( monkeypatch, extra_arguments, waited_message, url_keyword, mock_synchronisation_url ): - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json - ) - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json - ) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json) arguments = { 'auth_keycloak_url': 'http://keycloak.url/auth', 'auth_username': 'test_admin', @@ -270,12 +247,8 @@ def mock_fail_synchronisation_url(mocker): def test_fail_synchronisation(monkeypatch, mock_fail_synchronisation_url): - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json - ) - monkeypatch.setattr( - keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json - ) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_synchronization.AnsibleModule, 'fail_json', fail_json) arguments = { 'auth_keycloak_url': 'http://keycloak.url/auth', 'auth_username': 'test_admin', From dfb12ae0d84a04540523ebafaf248243678b3e29 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Wed, 26 Jun 2019 11:41:28 +0200 Subject: [PATCH 72/79] feature: CRUD group ldap mapper --- .../identity/keycloak/keycloak.py | 4 +- .../keycloak/keycloak_ldap_federation.py | 32 +- .../module_utils/identity/keycloak/utils.py | 68 ++ .../keycloak/keycloak_ldap_federation.py | 130 +--- .../keycloak/keycloak_ldap_group_mapper.py | 709 ++++++++++++++++++ .../keycloak/keycloak_ldap_synchronization.py | 2 +- .../test_kecyloak_ldap_group_mapper.py | 604 +++++++++++++++ 7 files changed, 1423 insertions(+), 126 deletions(-) create mode 100644 lib/ansible/module_utils/identity/keycloak/utils.py create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py create mode 100644 test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py index 86e9cbb09038af..4f7ef1ab2fd08e 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -156,7 +156,7 @@ def call_with_payload_on_url(url, restheaders, module, description, method, repr headers= restheaders, data=pushed_data, validate_certs=validate_certs) - except Exception as e: + except HTTPError as e: module.fail_json( msg=to_text("Could not %s %s in realm %s: %s" % ( method_verb[method], description, realm, str(e)))) @@ -177,7 +177,7 @@ def delete_on_url(url, restheaders, module, description): return open_url(url, method='DELETE', headers=restheaders, validate_certs=validate_certs) - except Exception as e: + except HTTPError as e: module.fail_json( msg="Could not delete %s in realm %s: %s" % (description, realm, str(e))) diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py index e39c110001d62b..6bc4c303a9ba87 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py @@ -1,20 +1,23 @@ # Copyright: (c) 2018, Nicolas Duclert # 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 -from copy import deepcopy +__metaclass__ = type +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config from ansible.module_utils.identity.keycloak.keycloak import get_on_url from ansible.module_utils.six.moves.urllib.parse import quote USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' +COMPONENTS_URL = '{url}/admin/realms/{realm}/components/' class LdapFederationBase(object): def __init__(self, module, connection_header): self.module = module self.restheaders = connection_header - self.federation = self._clean_payload( + self.federation = clean_payload_with_config( self.get_federation(), credential_clean=False ) try: @@ -66,31 +69,6 @@ def get_federation(self): return json_federation return {} - @staticmethod - def _clean_payload(payload, credential_clean=True): - """Clean the payload from credentials and extra list. - - :param payload: the payload given to the post or put request. - :return: the cleaned payload - :rtype: dict - """ - if not payload: - return {} - clean_payload = deepcopy(payload) - old_config = clean_payload.pop('config') - new_config = {} - for key, value in old_config.items(): - if key == 'bindCredential' and credential_clean: - new_config.update({key: 'no_log'}) - else: - try: - new_config.update({key: value[0]}) - except IndexError: - new_config.update({key: None}) - - clean_payload.update({'config': new_config}) - return clean_payload - @property def given_id(self): """Get the asked id given by the user. diff --git a/lib/ansible/module_utils/identity/keycloak/utils.py b/lib/ansible/module_utils/identity/keycloak/utils.py new file mode 100644 index 00000000000000..d0721f26444108 --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/utils.py @@ -0,0 +1,68 @@ +# Copyright: (c) 2018, Nicolas Duclert +# 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 copy import deepcopy + + +def if_absent_add_a_default_value(payload, dict_of_default, config=True): + for key in dict_of_default: + try: + payload[key] + except KeyError: + if dict_of_default[key] is None: + if config: + payload.update({key: []}) + else: + payload.update({key: ''}) + else: + if config: + payload.update({key: [dict_of_default[key]]}) + else: + payload.update({key: dict_of_default[key]}) + return payload + + +def snake_to_point_case(word): + return word.replace('_', '.') + + +def convert_to_bool(bool_argument): + if isinstance(bool_argument, bool): + return bool_argument + if isinstance(bool_argument, str): + if bool_argument.lower() == 'false': + return False + if bool_argument.lower() == 'true': + return True + return bool(bool_argument) + + +def clean_payload_with_config(payload, credential_clean=True): + """Clean the payload from credentials and extra list. + + :param payload: the payload given to the post or put request. + :return: the cleaned payload + :rtype: dict + """ + if not payload: + return {} + clean_payload = deepcopy(payload) + try: + old_config = clean_payload.pop('config') + except TypeError: + clean_payload = clean_payload[0] + old_config = clean_payload.pop('config') + new_config = {} + for key, value in old_config.items(): + if key == 'bindCredential' and credential_clean: + new_config.update({key: 'no_log'}) + else: + try: + new_config.update({key: value[0]}) + except IndexError: + new_config.update({key: None}) + + clean_payload.update({'config': new_config}) + return clean_payload diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 5ce8de801cded6..884f607bbb09f0 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -7,11 +7,7 @@ __metaclass__ = type -ANSIBLE_METADATA = { - 'metadata_version': '1.1', - 'status': ['preview'], - 'supported_by': 'community', -} +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} DOCUMENTATION = r''' --- @@ -575,6 +571,11 @@ ) from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( LdapFederationBase, + COMPONENTS_URL, +) +from ansible.module_utils.identity.keycloak.utils import ( + clean_payload_with_config, + if_absent_add_a_default_value, ) from ansible.module_utils.common.dict_transformations import dict_merge, recursive_diff from ansible.module_utils.basic import AnsibleModule @@ -584,7 +585,6 @@ USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' -COMPONENTS_URL = '{url}/admin/realms/{realm}/components/' TEST_LDAP_CONNECTION = '{url}/admin/realms/{realm}/testLDAPConnection' SEARCH_SCOPE = {'one level': 1, 'subtree': 2} @@ -601,10 +601,7 @@ def delete(self): """Delete the federation""" federation_url = self._get_federation_url() delete_on_url( - federation_url, - self.restheaders, - self.module, - 'federation %s' % self.given_id, + federation_url, self.restheaders, self.module, 'federation %s' % self.given_id ) def update(self, check=False): @@ -617,7 +614,7 @@ def update(self, check=False): return {} federation_payload = self._create_payload() if check: - return self._clean_payload(federation_payload) + return clean_payload_with_config(federation_payload) put_url = USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), @@ -635,13 +632,13 @@ def update(self, check=False): 'federation %s' % self.given_id, federation_payload, ) - return self._clean_payload(federation_payload) + return clean_payload_with_config(federation_payload) def _arguments_update_representation(self): - clean_payload = self._clean_payload( - self._create_payload(), credential_clean=False - ) + clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) payload_diff, _ = recursive_diff(clean_payload, self.federation) + payload_diff.pop('providerId') + payload_diff.pop('providerType') if not payload_diff: return False return True @@ -675,7 +672,7 @@ def create(self): 'federation %s' % self.given_id, federation_payload, ) - return self._clean_payload(federation_payload) + return clean_payload_with_config(federation_payload) def _test_connection(self): """Test the connection to the LDAP server""" @@ -691,10 +688,7 @@ def _test_authentication(self): self.module.fail_json( msg='The user %s cannot logged in the ldap at %s, ' 'you should check your credentials.' - % ( - self.module.params.get('bind_dn'), - self.module.params.get('connection_url'), - ) + % (self.module.params.get('bind_dn'), self.module.params.get('connection_url')) ) def _call_test_url(self, extra_arguments): @@ -735,21 +729,14 @@ def _call_test_url(self, extra_arguments): payload.update({camel(one_key): trust_store}) else: payload.update( - { - camel(one_key): self.federation['config'].get( - camel(one_key), '' - ) - } + {camel(one_key): self.federation['config'].get(camel(one_key), '')} ) payload.update(extra_arguments) test_url = TEST_LDAP_CONNECTION.format( - url=self.module.params.get('auth_keycloak_url'), - realm=self.module.params.get('realm'), + url=self.module.params.get('auth_keycloak_url'), realm=self.module.params.get('realm') ) headers = deepcopy(self.restheaders) - headers.update( - {'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'} - ) + headers.update({'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'}) try: open_url( test_url, @@ -818,9 +805,7 @@ def _create_payload(self): else: config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) try: - old_configuration = { - key: [value] for key, value in self.federation['config'].items() - } + old_configuration = {key: [value] for key, value in self.federation['config'].items()} except KeyError: old_configuration = {} new_configuration = dict_merge(old_configuration, config) @@ -837,33 +822,17 @@ def _create_payload(self): 'batchSizeForSync': 1000, } payload.update( - { - 'config': self._if_absent_add_a_default_value( - new_configuration, dict_of_default - ) - } + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} ) return payload - @staticmethod - def _if_absent_add_a_default_value(payload, dict_of_default): - for key in dict_of_default: - try: - payload[key] - except KeyError: - if dict_of_default[key] is None: - payload.update({key: []}) - else: - payload.update({key: [dict_of_default[key]]}) - return payload - def get_result(self): """Get the payload cleaned of credentials and lists. :return: the cleaned payload :rtype: dict """ - return self._clean_payload(self._create_payload()) + return clean_payload_with_config(self._create_payload()) def check_mandatory_arguments(self, creation_payload): """Check if mandatory arguments for federation creation are present. @@ -909,30 +878,19 @@ def run_module(): federation_id=dict(type='str', aliases=['federerationId']), federation_uuid=dict(type='str', aliases=['federationUuid']), pagination=dict(type='bool'), - vendor=dict( - type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory'] - ), + vendor=dict(type='str', choices=['other', 'ad', 'rhds', 'tivoli', 'edirectory']), edit_mode=dict( - type='str', - choices=['READ_ONLY', 'UNSYNCED', 'WRITABLE'], - aliases=['editMode'], + type='str', choices=['READ_ONLY', 'UNSYNCED', 'WRITABLE'], aliases=['editMode'] ), import_enable=dict(type='bool', aliases=['importEnable']), username_ldap_attribute=dict( type='str', - aliases=[ - 'usernameLDAPAttribute', - 'username_LDAP_attribute', - 'usernameLdapAttribute', - ], + aliases=['usernameLDAPAttribute', 'username_LDAP_attribute', 'usernameLdapAttribute'], ), rdn_ldap_attribute=dict( - type='str', - aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'], - ), - user_object_classes=dict( - type='list', elements='str', aliases=['userObjectClasses'] + type='str', aliases=['rdnLDAPAttribute', 'rdnLdapAttribute', 'rdn_LDAP_attribute'] ), + user_object_classes=dict(type='list', elements='str', aliases=['userObjectClasses']), connection_url=dict(type='str', aliases=['connectionUrl']), users_dn=dict(type='str', aliases=['usersDn']), bind_dn=dict(type='str', aliases=['bindDn']), @@ -948,38 +906,24 @@ def run_module(): ], ), uuid_ldap_attribute=dict( - type='str', - aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', 'uuid_LDAP_attribute'], - ), - search_scope=dict( - type='str', choices=['one level', 'subtree'], aliases=['searchScope'] + type='str', aliases=['uuidLDAPAttribute', 'uuidLdapAttribute', 'uuid_LDAP_attribute'] ), + search_scope=dict(type='str', choices=['one level', 'subtree'], aliases=['searchScope']), use_truststore_spi=dict( - type='str', - choices=['ldapsOnly', 'always', 'never'], - aliases=['useTruststoreSpi'], + type='str', choices=['ldapsOnly', 'always', 'never'], aliases=['useTruststoreSpi'] ), test_connection=dict(type='bool', aliases=['testConnection']), test_authentication=dict(type='bool', aliases=['testAuthentication']), synchronize_registrations=dict( type='bool', - aliases=[ - 'sync_registrations', - 'synchronizeRegistrations', - 'syncRegistrations', - ], + aliases=['sync_registrations', 'synchronizeRegistrations', 'syncRegistrations'], ), batch_size_for_synchronization=dict( type='int', - aliases=[ - 'batch_size_for_sync', - 'batchSizeForSynchronization', - 'batchSizeForSync', - ], + aliases=['batch_size_for_sync', 'batchSizeForSynchronization', 'batchSizeForSync'], ), full_synchronization_period=dict( - type='int', - aliases=['full_sync_period', 'fullSynchronizationPeriod', 'fullSyncPeriod'], + type='int', aliases=['full_sync_period', 'fullSynchronizationPeriod', 'fullSyncPeriod'] ), changed_user_synchronization_period=dict( type='int', @@ -1038,9 +982,7 @@ def run_module(): if not module.check_mode: ldap_federation.delete() result['msg'] = to_text( - 'Federation {given_id} deleted.'.format( - given_id=ldap_federation.given_id - ) + 'Federation {given_id} deleted.'.format(given_id=ldap_federation.given_id) ) result['changed'] = True result['ldap_federation'] = {} @@ -1052,9 +994,7 @@ def run_module(): payload = ldap_federation.get_result() result['msg'] = to_text( - 'Federation {given_id} created.'.format( - given_id=ldap_federation.given_id - ) + 'Federation {given_id} created.'.format(given_id=ldap_federation.given_id) ) result['changed'] = True result['ldap_federation'] = payload @@ -1065,9 +1005,7 @@ def run_module(): payload = ldap_federation.update(check=True) if payload: result['msg'] = to_text( - 'Federation {given_id} updated.'.format( - given_id=ldap_federation.given_id - ) + 'Federation {given_id} updated.'.format(given_id=ldap_federation.given_id) ) result['changed'] = True else: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py new file mode 100644 index 00000000000000..0de74eb608a994 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -0,0 +1,709 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: keycloak_ldap_group_mapper + +short_description: Allows administration of Keycloak LDAP group mapper via Keycloak API +description: + - This module allows you to add, remove or modify Keycloak LDAP group mapper via the Keycloak 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. + + - When updating a LDAP group mapper federation, where possible provide the mapper ID to the + module. This removes a lookup to the API to translate the name into the mapper ID. + + - This module has been tested against keycloak 6.0, the backward compatibility is not garanteed. + +version_added: "2.10" +options: + state: + description: + - State of the LDAP federation. + - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the group will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP federation resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federation in the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with federation_uuid and one + of them is required by the module + type: str + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with federation_id and one + of them is required by the module + type: str + + mapper_name: + description: + - The name of the group mapper + - This parameter is mutually exclusive with mapper_uuid and one + of them is required by the module + type: str + + mapper_uuid: + description: + - The uuid of the group mapper + - This parameter is mutually exclusive with mapper_name and one + of them is required by the module + type: str + + groups_dn: + description: + - LDAP DN where are groups of this tree saved + - This parameter is mandatory when creating a new LDAP group mapper + type: str + + group_name_ldap_attribute: + description: + - Name of LDAP attribute, which is used in group objects for name and RDN of group + type: str + + group_object_classes: + description: + - List of class of the group object + type: list + + preserve_group_inheritance: + description: + - Flag whether group inheritance from LDAP should be propagated to Keycloak + - If C(no), then all LDAP groups will be mapped as flat top-level groups in Keycloak. + Otherwise group inheritance is preserved into Keycloak, but the group sync might fail if + LDAP structure contains recursions or multiple parent groups per child groups + - If C(yes), this arguments is incompatible with I(membership_attribute_type=UID) + type: bool + + ignore_missing_groups: + description: + - Ignore missing groups in the group hierarchy + type: bool + + membership_ldap_attribute: + description: + - Name of LDAP attribute on group, which is used for membership mappings + type: str + + membership_attribute_type: + description: + - Describe the way the members of the group are declared + - C(DN): LDAP group has it's members declared in form of their full DN, + - C(UID): LDAP group has it's members declared in form of pure user uids + - If C(UID), this arguments is incompatible with I(preserve_group_inheritance=yes) + choices: + - DN + - UID + type: str + + membership_user_ldap_attribute: + description: + - Used just if I(membership_attribute_type=UID) + - It is name of LDAP attribute on user, which is used for membership mappings + type: str + + groups_ldap_filter: + description: + - LDAP Filter adds additional custom filter to the whole query for retrieve LDAP groups + - Filter must start with '(' and ends with ')' + type: str + + mode: + description: + - Select the way group will be created or not in the Keycloak database + - C(LDAP_ONLY): all group mappings of users are retrieved from LDAP and saved into LDAP + - C(READ_ONLY): read-only LDAP mode where group mappings are retrieved from both LDAP and + DB and merged together. New group joins are not saved to LDAP but to DB. + - C(IMPORT): read-only LDAP mode where group mappings are retrieved from LDAP just at the + time when user is imported from LDAP and then they are saved to local keycloak DB. + choices: + - LDAP_ONLY + - READ_ONLY + - IMPORT + type: str + + user_groups_retrieve_strategy: + description: + - Specify how to retrieve groups of user + - C(LOAD_GROUPS_BY_MEMBER_ATTRIBUTE): groups of user will be retrieved by sending LDAP + query to retrieve all groups where 'member' is our user + - C(GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE): groups of user will be retrieved from + memberOf attribute of our user. Or from the other attribute specified by + I(member_of_ldap_attribute) + - C(LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY): applicable just in Active Directory and it + means that groups of user will be retrieved recursively with usage of + LDAP_MATCHING_RULE_IN_CHAIN Ldap extension. + choices: + - LOAD_GROUPS_BY_MEMBER_ATTRIBUTE + - GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE + - LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY + + member_of_ldap_attribute: + description: + - Used just when I(user_groups_retrieve_strategy=GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE) + - It specifies the name of the LDAP attribute on the LDAP user, which contains the groups, + which the user is member of + type: str + + mapped_group_attributes: + description: + - This points to the list of attributes on LDAP group, which will be mapped as attributes of + group in Keycloak. + type: list + + drop_non_existing_groups_during_sync: + description: + - If C(yes), then during sync of groups from LDAP to Keycloak, Keycloak groups which don't + exists in LDAP will be deleted + type: bool + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = r''' +- name: Create a LDAP group mapper + keycloak_ldap_group_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + federation_id: my-company-ldap + groups_dn: ou=Group,dc=MyCompany + membership_attribute_type: DN +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Group mapper my-group-mapper created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +group_mapper: + description: the LDAP group mapper representation. Empty if the asked group mapper is deleted or does not exist. + returned: always + type: dict + contains: + name: + description: name of the LDAP group mapper + type: str + returned: on success + sample: group-ldap-mapper1 + providerId: + description: the id of the group mapper, always C(group-ldap-mapper) for this module + type: str + returned: on success + sample: group-ldap-mapper + parentId: + description: the LDAP group mapper parent uuid + type: str + returned: on success + sample: de455375-6900-46a0-8d11-51554e1c3f18 + providerType: + description: the type of the object, for this module always C(org.keycloak.storage.ldap.mappers.LDAPStorageMapper) + type: str + returned: on success + sample: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + config: + description: the configuration of the LDAP group mapper + type: dict + returned: on success + contains: + groups.dn: + description: LDAP DN where are groups of this tree saved + type: str + returned: on success + sample: ou=Group,dc=NewCompany + group.name.ldap.attribute: + description: Name of LDAP attribute, which is used in group objects for name and RDN of group + type: str + returned: on success + sample: cn + group.object.classes: + description: List of class of the group object + type: str + returned: on success + sample: groupOfNames + preserve.group.inheritance: + description: Flag whether group inheritance from LDAP should be propagated to Keycloak + type: bool + returned: on success + sample: true + groups.ldap.filter: + description: LDAP Filter adds additional custom filter to the whole query for retrieve LDAP groups + type: str + returned: on success + sample: (groupType=2147483652) + ignore.missing.groups: + description: Ignore missing groups in the group hierarchy + type: bool + returned: on success + sample: false + mapped.group.attributes: + description: This points to the list of attributes on LDAP group, which will be mapped as attributes of group in Keycloak. + type: str + returned: on success + sample: attribute1, attribute2 + membership.ldap.attribute: + description: specifies the name of the LDAP attribute on the LDAP user + type: str + returned: on success + sample: memberOf + membership.attribute.type: + description: Describe the way the members of the group are declared + type: str + returned: on success + sample: DN + membership.user.ldap.attribute: + description: Describe the way the members of the group are declared + type: str + returned: on success + sample: DN + mode: + description: Select the way group will be created or not in the Keycloak database + type: str + returned: on success + sample: READ_ONLY + user.roles.retrieve.strategy: + description: Specify how to retrieve groups of user + type: str + returned: on success + sample: LOAD_GROUPS_BY_MEMBER_ATTRIBUTE + memberof.ldap.attribute: + description: specifies the name of the LDAP attribute on the LDAP user + type: str + returned: on success + sample: memberOf + drop.non.existing.groups.during.sync: + description: Specify if Keycloak groups which don't exists in LDAP will be deleted + type: str + returned: on success + sample: false +''' + +from copy import deepcopy + +from ansible.module_utils.common.dict_transformations import recursive_diff, dict_merge + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import quote +from ansible.module_utils.identity.keycloak.utils import ( + clean_payload_with_config, + if_absent_add_a_default_value, +) +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + get_token, + KeycloakError, + get_on_url, + delete_on_url, + put_on_url, + post_on_url, +) +from ansible.module_utils.identity.keycloak.utils import snake_to_point_case, convert_to_bool +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( + LdapFederationBase, + COMPONENTS_URL, +) + +GROUP_MAPPER_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' +GROUP_MAPPER_BY_NAME = '{url}/admin/realms/{realm}/components?parent={federation_uuid}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name={mapper_name}' + +USER_GROUP_RETRIEVE_STRATEGY_LABEL = 'user.roles.retrieve.strategy' + + +class FederationGroupMapper(object): + def __init__(self, module, connection_header): + self.module = module + self.restheaders = connection_header + self.description = 'mapper {}'.format(self.given_id) + if self.module.params.get('mapper_name'): + self.federation = LdapFederationBase(module, connection_header) + if not self.federation.uuid: + raise KeycloakError( + 'Cannot access mapper because {} federation does not exist.'.format( + self.federation.given_id + ) + ) + else: + self.federation = None + self.representation = clean_payload_with_config( + get_on_url( + url=self._get_mapper_url(), + restheaders=self.restheaders, + module=self.module, + description=self.description, + ) + ) + try: + self.uuid = self.representation['id'] + except KeyError: + self.uuid = '' + else: + if self.representation['providerId'] != 'group-ldap-mapper': + raise KeycloakError( + '{given_id} is not a group mapper.'.format(given_id=self.given_id)) + + @property + def given_id(self): + """Get the asked mapper id given by the user. + + :return the asked id given by the user as a name or an uuid. + :rtype: str + """ + if self.module.params.get('mapper_name'): + return self.module.params.get('mapper_name') + return self.module.params.get('mapper_uuid') + + def _get_mapper_url(self): + """Create the url in order to get the federation from the given argument (uuid or name) + :return: the url as string + :rtype: str""" + try: + return GROUP_MAPPER_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=self.uuid, + ) + except AttributeError: + if self.module.params.get('mapper_name'): + return GROUP_MAPPER_BY_NAME.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + mapper_name=quote(self.module.params.get('mapper_name').lower()), + federation_uuid=self.federation.uuid, + ) + return GROUP_MAPPER_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=quote(self.module.params.get('mapper_uuid')), + ) + + def _create_payload(self): + translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} + # manage a typo in the api in order to have the correct name in the payload. + config_translation = { + 'user_groups_retrieve_strategy': USER_GROUP_RETRIEVE_STRATEGY_LABEL, + 'member_of_ldap_attribute': 'memberof.ldap.attribute', + } + + not_mapper_argument = list(keycloak_argument_spec().keys()) + [ + 'state', + 'realm', + 'federation_id', + ] + + config = {} + payload = { + 'providerId': 'group-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + if self.federation: + payload.update({'parentId': self.federation.uuid}) + for key, value in self.module.params.items(): + if value is not None and key not in not_mapper_argument: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + elif key in list(config_translation.keys()): + config.update({config_translation[key]: [value]}) + elif key == 'groups_ldap_filter': + config.update({snake_to_point_case(key): [value.strip()]}) + elif key in ['mapped_group_attributes', 'group_object_classes']: + config.update({snake_to_point_case(key): [','.join(value)]}) + else: + config.update({snake_to_point_case(key): [value]}) + try: + old_configuration = { + key: [value] for key, value in self.representation['config'].items() + } + except KeyError: + old_configuration = {} + new_configuration = dict_merge(old_configuration, config) + self._check_arguments(new_configuration) + dict_of_default = { + 'groups.dn': 'ab', + 'group.name.ldap.attribute': 'cn', + 'group.object.classes': 'groupOfNames', + 'preserve.group.inheritance': 'true', + 'ignore.missing.groups': 'false', + 'membership.ldap.attribute': 'member', + 'membership.attribute.type': 'DN', + 'membership.user.ldap.attribute': 'cn', + 'groups.ldap.filter': '', + 'mode': 'LDAP_ONLY', + 'user.roles.retrieve.strategy': 'LOAD_GROUPS_BY_MEMBER_ATTRIBUTE', + 'memberof.ldap.attribute': 'memberOf', + 'mapped.group.attributes': '', + 'drop.non.existing.groups.during.sync': 'false', + } + payload.update( + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} + ) + return payload + + @staticmethod + def _check_arguments(new_configuration): + try: + preserve_group_inheritance = convert_to_bool( + new_configuration['preserve.group.inheritance'][0] + ) + membership_attribute_type = new_configuration['membership.attribute.type'][0] + except KeyError: + pass + else: + if preserve_group_inheritance and (membership_attribute_type == 'UID'): + raise KeycloakError( + 'Not possible to preserve group inheritance and use UID membership type together.' + ) + try: + user_groups_retrieve_strategy = new_configuration[USER_GROUP_RETRIEVE_STRATEGY_LABEL][ + 0 + ] + member_of_ldap_attribute = new_configuration['memberof.ldap.attribute'][0] + except KeyError: + pass + else: + if ( + user_groups_retrieve_strategy != 'GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE' + and member_of_ldap_attribute + ): + raise KeycloakError( + 'member of ldap attribute is only useful when user groups strategy is get groups ' + 'from user member of attribute.' + ) + try: + ldap_filter = new_configuration[snake_to_point_case('groups_ldap_filter')][0].strip() + except KeyError: + pass + else: + if ldap_filter[0] != '(' and ldap_filter[-1] != ')': + raise KeycloakError( + 'LDAP filter should begin with a opening bracket and end with closing braket.' + ) + + def delete(self): + delete_on_url(self._get_mapper_url(), self.restheaders, self.module, self.description) + + def _arguments_update_representation(self): + clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) + payload_without_empty_values = deepcopy(clean_payload) + for key, value in clean_payload['config'].items(): + if not value: + payload_without_empty_values['config'].pop(key) + payload_diff, _ = recursive_diff(payload_without_empty_values, self.representation) + try: + config_diff = payload_diff.pop('config') + except KeyError: + config_diff = {} + if not payload_diff and not config_diff: + return False + return True + + def update(self, check=False): + if not self._arguments_update_representation(): + return {} + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + put_on_url( + self._get_mapper_url(), self.restheaders, self.module, self.description, payload + ) + return clean_payload_with_config(payload) + + def create(self, check=False): + if not self.module.params.get('groups_dn'): + raise KeycloakError('groups_dn is mandatory for group mapper creation.') + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + post_url = COMPONENTS_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + ) + post_on_url( + url=post_url, + restheaders=self.restheaders, + module=self.module, + description=self.description, + representation=payload, + ) + return clean_payload_with_config(payload) + + +def run_module(): + argument_spec = keycloak_argument_spec() + meta_args = dict( + realm=dict(type='str', default='master'), + federation_id=dict(type='str'), + federation_uuid=dict(type='str'), + mapper_name=dict(type='str'), + mapper_uuid=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']), + groups_dn=dict(type='str'), + group_name_ldap_attribute=dict(type='str'), + group_object_classes=dict(type='list'), + preserve_group_inheritance=dict(type='bool'), + ignore_missing_groups=dict(type='bool'), + membership_ldap_attribute=dict(type='str'), + membership_attribute_type=dict(type='str', choices=['DN', 'UID']), + membership_user_ldap_attribute=dict(type='str'), + groups_ldap_filter=dict(type='str'), # should begin with a ( and end with another ) + mode=dict(type='str', choices=['LDAP_ONLY', 'IMPORT', 'READ_ONLY']), + user_groups_retrieve_strategy=dict( + type='str', + choices=[ + 'LOAD_GROUPS_BY_MEMBER_ATTRIBUTE', + 'GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE', + 'LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY', + ], + ), + member_of_ldap_attribute=dict(type='str'), + mapped_group_attributes=dict(type='list'), + drop_non_existing_groups_during_sync=dict(type='bool'), + ) + argument_spec.update(meta_args) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['mapper_name', 'mapper_uuid']], + mutually_exclusive=[['mapper_name', 'mapper_uuid']], + ) + if module.params.get('mapper_name') and not ( + module.params.get('federation_id') or module.params.get('federation_uuid') + ): + module.fail_json( + msg='With mapper name, the federation_id or federation_uuid must be given.', + changed=False, + group_mapper={}, + ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, group_mapper={}) + try: + federation_group_mapper = FederationGroupMapper(module, connection_header) + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, group_mapper={}) + waited_state = module.params.get('state') + try: + result = crud_group_mapper(federation_group_mapper, module, waited_state) + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, group_mapper={}) + + module.exit_json(**result) + + +def crud_group_mapper(federation_group_mapper, module, waited_state): + if waited_state == 'absent': + if federation_group_mapper.representation: + if not module.check_mode: + federation_group_mapper.delete() + result = { + 'changed': True, + 'msg': 'Group mapper {given_id} deleted.'.format( + given_id=federation_group_mapper.given_id + ), + 'group_mapper': {}, + } + else: + result = { + 'changed': False, + 'msg': 'Group mapper {given_id} does not exist, doing nothing.'.format( + given_id=federation_group_mapper.given_id + ), + 'group_mapper': federation_group_mapper.representation, + } + else: + if federation_group_mapper.representation: + if module.check_mode: + payload = federation_group_mapper.update(check=True) + else: + payload = federation_group_mapper.update() + if payload: + result = { + 'msg': to_text( + 'Group mapper {given_id} updated.'.format( + given_id=federation_group_mapper.given_id + ) + ), + 'changed': True, + 'group_mapper': payload, + } + else: + result = { + 'changed': False, + 'msg': '{} group mapper up to date, doing nothing.'.format( + federation_group_mapper.given_id + ), + 'group_mapper': federation_group_mapper.representation, + } + else: + if module.check_mode: + payload = federation_group_mapper.create(check=True) + else: + payload = federation_group_mapper.create() + result = { + 'msg': to_text( + 'Group mapper {given_id} created.'.format( + given_id=federation_group_mapper.given_id + ) + ), + 'changed': True, + 'group_mapper': payload, + } + + return result + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py index 8ea6ae3a33a572..0bfe3c5a1658b7 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -264,7 +264,7 @@ def run_module(): client_secret=module.params.get('auth_client_secret'), ) except KeycloakError as err: - module.fail_json(err) + module.fail_json(msg=err) ldap_synchronization = LdapSynchronization(module, connection_header) ldap_synchronization.synchronize() result = { diff --git a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py new file mode 100644 index 00000000000000..cf8e0223cd6c30 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py @@ -0,0 +1,604 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import json +from copy import deepcopy +from itertools import count + +import pytest +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config +from ansible.modules.identity.keycloak import keycloak_ldap_group_mapper +from ansible.module_utils.six import StringIO +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) + +MAPPER_DICT = { + 'config': { + 'groups.dn': ['ou=Group,dc=my-company,dc=io'], + 'group.name.ldap.attribute': ['cn'], + 'group.object.classes': ['groupOfNames'], + 'preserve.group.inheritance': ['true'], + 'ignore.missing.groups': ['false'], + 'membership.ldap.attribute': ['member'], + 'membership.attribute.type': ['DN'], + 'membership.user.ldap.attribute': ['cn'], + 'mode': ['LDAP_ONLY'], + 'user.roles.retrieve.strategy': ['LOAD_GROUPS_BY_MEMBER_ATTRIBUTE'], + 'memberof.ldap.attribute': [''], + 'drop.non.existing.groups.during.sync': ['false'], + }, + 'name': 'group-ldap-mapper1', + 'providerId': 'group-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'id': '123-123', +} + +WRONG_TYPE_MAPPER = deepcopy(MAPPER_DICT) +WRONG_TYPE_MAPPER.update({'providerId': 'role-ldap-mapper'}) + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def test_mapper_name_without_federation_id_should_fail(monkeypatch): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'With mapper name, the federation_id or federation_uuid must be given.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] + + +@pytest.fixture +def mock_absent_federation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=does-not-exist': create_wrapper( + json.dumps([]) + ) + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_federation_does_not_exist_fail(monkeypatch, mock_absent_federation_url): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'does-not-exist', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'Cannot access mapper because does-not-exist federation does not exist.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] + + +@pytest.fixture() +def mock_wrong_type(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( + json.dumps(WRONG_TYPE_MAPPER) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_good_name_but_wrong_type_should_raise_an_error(monkeypatch, mock_wrong_type): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'my-company-ldap', + 'mapper_name': 'group-ldap-mapper1', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'group-ldap-mapper1 is not a group mapper.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] + + +@pytest.fixture() +def mock_existing_mapper(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps(MAPPER_DICT) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'mapper_name': 'group-ldap-mapper1', 'federation_id': 'my-company-ldap'}, + {'mapper_uuid': '123-123'}, + ], + ids=['mapper and federation names', 'mapper uuid'], +) +def test_present_group_mapper_without_properties_should_do_nothing( + monkeypatch, extra_arguments, mock_existing_mapper +): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + try: + given_id = extra_arguments['mapper_name'] + except KeyError: + given_id = extra_arguments['mapper_uuid'] + assert ansible_exit_json['msg'] == '{} group mapper up to date, doing nothing.'.format( + given_id + ) + assert not ansible_exit_json['changed'] + assert ansible_exit_json['group_mapper'] == { + 'name': 'group-ldap-mapper1', + 'providerId': 'group-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'id': '123-123', + 'config': { + 'groups.dn': 'ou=Group,dc=my-company,dc=io', + 'group.name.ldap.attribute': 'cn', + 'group.object.classes': 'groupOfNames', + 'preserve.group.inheritance': 'true', + 'ignore.missing.groups': 'false', + 'membership.ldap.attribute': 'member', + 'membership.attribute.type': 'DN', + 'membership.user.ldap.attribute': 'cn', + 'mode': 'LDAP_ONLY', + 'user.roles.retrieve.strategy': 'LOAD_GROUPS_BY_MEMBER_ATTRIBUTE', + 'memberof.ldap.attribute': '', + 'drop.non.existing.groups.during.sync': 'false', + }, + } + + +@pytest.fixture() +def mock_delete_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_absent_should_delete_existing_mapper(monkeypatch, mock_delete_url): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'group-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'state': 'absent', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Group mapper group-ldap-mapper1 deleted.' + assert ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] + delete_call = mock_delete_url.mock_calls[3] + assert delete_call[2]['method'] == 'DELETE' + + +@pytest.fixture() +def mock_update_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_url): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'group-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'groups_dn': 'ou=Group,dc=NewCompany,dc=io', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Group mapper group-ldap-mapper1 updated.' + assert ansible_exit_json['changed'] + update_call_arguments = json.loads(mock_update_url.mock_calls[3][2]['data']) + reference_arguments = { + 'config': { + 'groups.dn': ['ou=Group,dc=NewCompany,dc=io'], + 'group.name.ldap.attribute': ['cn'], + 'group.object.classes': ['groupOfNames'], + 'preserve.group.inheritance': ['true'], + 'groups.ldap.filter': [''], + 'ignore.missing.groups': ['false'], + 'mapped.group.attributes': [''], + 'membership.ldap.attribute': ['member'], + 'membership.attribute.type': ['DN'], + 'membership.user.ldap.attribute': ['cn'], + 'mode': ['LDAP_ONLY'], + 'user.roles.retrieve.strategy': ['LOAD_GROUPS_BY_MEMBER_ATTRIBUTE'], + 'memberof.ldap.attribute': [''], + 'drop.non.existing.groups.during.sync': ['false'], + }, + 'name': 'group-ldap-mapper1', + 'providerId': 'group-ldap-mapper', + 'parentId': '456-456', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + assert update_call_arguments == reference_arguments + assert ansible_exit_json['group_mapper'] == clean_payload_with_config(reference_arguments) + + +@pytest.mark.parametrize( + 'extra_arguments, waited_error', + [ + ( + {'preserve_group_inheritance': 'yes', 'membership_attribute_type': 'UID'}, + 'Not possible to preserve group inheritance and use UID membership type together.', + ), + ( + { + 'member_of_ldap_attribute': 'plop', + 'user_groups_retrieve_strategy': 'LOAD_GROUPS_BY_MEMBER_ATTRIBUTE', + }, + ( + 'member of ldap attribute is only useful when user groups strategy is get groups ' + 'from user member of attribute.' + ), + ), + ( + {'groups_ldap_filter': 'without parenthesis'}, + 'LDAP filter should begin with a opening bracket and end with closing braket.', + ), + ], + ids=[ + 'inheritance and membership attribute type', + 'retrieve strategy and member of ldap attribute', + 'LDAP filter checks', + ], +) +def test_incompatible_arguments_should_fail( + monkeypatch, mock_update_url, extra_arguments, waited_error +): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'group-ldap-mapper1', + 'federation_id': 'my-company-ldap', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] + assert ansible_exit_json['msg'] == waited_error + + +@pytest.fixture() +def mock_create_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( + json.dumps({}) + ), + 'http://keycloak.url/auth/admin/realms/master/components': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'group-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'groups_dn': 'ou=Group,dc=myCompany,dc=io', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + reference_payload = { + 'config': { + 'groups.dn': ['ou=Group,dc=myCompany,dc=io'], + 'group.name.ldap.attribute': ['cn'], + 'group.object.classes': ['groupOfNames'], + 'preserve.group.inheritance': ['true'], + 'ignore.missing.groups': ['false'], + 'membership.ldap.attribute': ['member'], + 'membership.attribute.type': ['DN'], + 'membership.user.ldap.attribute': ['cn'], + 'groups.ldap.filter': [''], + 'mode': ['LDAP_ONLY'], + 'user.roles.retrieve.strategy': ['LOAD_GROUPS_BY_MEMBER_ATTRIBUTE'], + 'memberof.ldap.attribute': ['memberOf'], + 'mapped.group.attributes': [''], + 'drop.non.existing.groups.during.sync': ['false'], + }, + 'name': 'group-ldap-mapper1', + 'providerId': 'group-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + } + + assert ansible_exit_json['msg'] == 'Group mapper group-ldap-mapper1 created.' + assert ansible_exit_json['changed'] + assert ansible_exit_json['group_mapper'] == clean_payload_with_config(reference_payload) + create_call_arguments = json.loads(mock_create_url.mock_calls[3][2]['data']) + assert create_call_arguments == reference_payload + + +@pytest.fixture() +def mock_does_not_exist_mapper(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=does-not-exist': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_absent_state_and_mapper_does_not_exist_should_do_nothing(monkeypatch, mock_does_not_exist_mapper): + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist', + 'federation_id': 'my-company-ldap', + 'state': 'absent', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_ldap_group_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + assert ansible_exit_json['msg'] == 'Group mapper does-not-exist does not exist, doing nothing.' + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['group_mapper'] From d1545a0d81d9eb37b2946e451c115c5a81577521 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Tue, 17 Sep 2019 15:29:28 +0200 Subject: [PATCH 73/79] refactor: use a function managing wanted state for any Keycloak representation --- .../module_utils/identity/keycloak/crud.py | 92 ++++++ .../keycloak/keycloak_ldap_federation.py | 29 +- .../keycloak/keycloak_ldap_federation.py | 70 +---- .../keycloak/keycloak_ldap_group_mapper.py | 131 ++------ .../test_kecyloak_ldap_group_mapper.py | 35 ++- .../keycloak/test_keycloak_ldap_federation.py | 292 ++++++++++-------- 6 files changed, 340 insertions(+), 309 deletions(-) create mode 100644 lib/ansible/module_utils/identity/keycloak/crud.py diff --git a/lib/ansible/module_utils/identity/keycloak/crud.py b/lib/ansible/module_utils/identity/keycloak/crud.py new file mode 100644 index 00000000000000..defc3f5f39a038 --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/crud.py @@ -0,0 +1,92 @@ +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +from ansible.module_utils._text import to_text + +__metaclass__ = type + + +def crud_with_instance(instance_to_crud, result_key): + """Function managing actions from the wanted state given by the user. + + :param instance_to_crud: a class with the following functions: + * delete: a function deleting the object on Keycloak, + * update: a function updating the object on Keycolak, + * create: a function creating the object on Keycloak, + and the following properties: + * module: the ansible module, + * initial_representation: the initial state of the instance on the Keycloak, + * representation: the state of the instance at the line of the program (this property do a new + call to Keycloak), + * description: the nature of the instance and its given id (mainly name or uuid). + :param result_key: the key name where the final representation will be written + :return: a dictionary with three keys: + * changed: if the Keycloak object has been modified, + * msg: a text message resuming the action done, + * result_key: the final representation of the object. + """ + module = instance_to_crud.module + waited_state = module.params.get('state') + + if waited_state == 'absent': + if instance_to_crud.initial_representation: + if not module.check_mode: + instance_to_crud.delete() + result = { + 'changed': True, + 'msg': '{description} deleted.'.format( + description=instance_to_crud.description.capitalize(), + ), + result_key: {}, + } + else: + result = { + 'changed': False, + 'msg': '{description} does not exist, doing nothing.'.format( + description=instance_to_crud.description.capitalize() + ), + result_key: {}, + } + else: + if instance_to_crud.initial_representation: + if module.check_mode: + payload = instance_to_crud.update(check=True) + else: + payload = instance_to_crud.update() + if payload: + result = { + 'msg': to_text( + '{description} updated.'.format( + description=instance_to_crud.description.capitalize() + ) + ), + 'changed': True, + result_key: instance_to_crud.representation, + } + else: + result = { + 'changed': False, + 'msg': '{description} up to date, doing nothing.'.format( + description=instance_to_crud.description.capitalize() + ), + result_key: instance_to_crud.initial_representation, + } + else: + if module.check_mode: + payload = instance_to_crud.create(check=True) + else: + instance_to_crud.create() + payload = instance_to_crud.representation + + result = { + 'msg': to_text( + '{description} created.'.format( + description=instance_to_crud.description.capitalize() + ) + ), + 'changed': True, + result_key: payload, + } + + return result \ No newline at end of file diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py index 6bc4c303a9ba87..00d884fb8ada6c 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py @@ -17,37 +17,32 @@ class LdapFederationBase(object): def __init__(self, module, connection_header): self.module = module self.restheaders = connection_header - self.federation = clean_payload_with_config( + self.uuid = self.module.params.get('federation_uuid') + self.initial_representation = clean_payload_with_config( self.get_federation(), credential_clean=False ) + self.description = 'federation {given_id}'.format(given_id=self.given_id) try: - self.uuid = self.federation['id'] + self.uuid = self.initial_representation['id'] except KeyError: - self.uuid = '' + pass def _get_federation_url(self): """Create the url in order to get the federation from the given argument (uuid or name) :return: the url as string :rtype: str """ - try: - return USER_FEDERATION_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=self.uuid, - ) - except AttributeError: - if self.module.params.get('federation_id'): - return USER_FEDERATION_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - federation_id=quote(self.module.params.get('federation_id')), - ) + if self.uuid: return USER_FEDERATION_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), - uuid=quote(self.module.params.get('federation_uuid')), + uuid=quote(self.uuid), ) + return USER_FEDERATION_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + federation_id=quote(self.module.params.get('federation_id')), + ) def get_federation(self): """Get the federation information from keycloak diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 884f607bbb09f0..6aec19815a7ebb 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -560,6 +560,7 @@ from copy import deepcopy from ansible.module_utils._text import to_text +from ansible.module_utils.identity.keycloak.crud import crud_with_instance from ansible.module_utils.identity.keycloak.keycloak import ( camel, keycloak_argument_spec, @@ -597,6 +598,12 @@ class LdapFederation(LdapFederationBase): def __init__(self, module, connection_header): super(LdapFederation, self).__init__(module, connection_header) + @property + def representation(self): + return clean_payload_with_config( + self.get_federation(), credential_clean=True + ) + def delete(self): """Delete the federation""" federation_url = self._get_federation_url() @@ -636,7 +643,7 @@ def update(self, check=False): def _arguments_update_representation(self): clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) - payload_diff, _ = recursive_diff(clean_payload, self.federation) + payload_diff, _ = recursive_diff(clean_payload, self.initial_representation) payload_diff.pop('providerId') payload_diff.pop('providerType') if not payload_diff: @@ -709,7 +716,7 @@ def _call_test_url(self, extra_arguments): works. """ try: - trust_store = self.federation['config']['useTruststoreSpi'] + trust_store = self.initial_representation['config']['useTruststoreSpi'] except KeyError: trust_store = 'ldapsOnly' federation_keys = [ @@ -729,7 +736,7 @@ def _call_test_url(self, extra_arguments): payload.update({camel(one_key): trust_store}) else: payload.update( - {camel(one_key): self.federation['config'].get(camel(one_key), '')} + {camel(one_key): self.initial_representation['config'].get(camel(one_key), '')} ) payload.update(extra_arguments) test_url = TEST_LDAP_CONNECTION.format( @@ -805,7 +812,7 @@ def _create_payload(self): else: config.update({camel(key).replace('Ldap', 'LDAP'): [value]}) try: - old_configuration = {key: [value] for key, value in self.federation['config'].items()} + old_configuration = {key: [value] for key, value in self.initial_representation['config'].items()} except KeyError: old_configuration = {} new_configuration = dict_merge(old_configuration, config) @@ -965,59 +972,10 @@ def run_module(): auth_password=module.params.get('auth_password'), client_secret=module.params.get('auth_client_secret'), ) + ldap_federation = LdapFederation(module, connection_header) + result = crud_with_instance(ldap_federation, 'ldap_federation') except KeycloakError as e: - module.fail_json(msg=str(e)) - ldap_federation = LdapFederation(module, connection_header) - waited_state = module.params.get('state') - result = {} - if waited_state == 'absent': - if not ldap_federation.federation: - result['msg'] = to_text( - 'Federation {given_id} does not exist, doing nothing.'.format( - given_id=ldap_federation.given_id - ) - ) - result['changed'] = False - else: - if not module.check_mode: - ldap_federation.delete() - result['msg'] = to_text( - 'Federation {given_id} deleted.'.format(given_id=ldap_federation.given_id) - ) - result['changed'] = True - result['ldap_federation'] = {} - else: - if not ldap_federation.federation: - if not module.check_mode: - payload = ldap_federation.create() - else: - payload = ldap_federation.get_result() - - result['msg'] = to_text( - 'Federation {given_id} created.'.format(given_id=ldap_federation.given_id) - ) - result['changed'] = True - result['ldap_federation'] = payload - else: - if not module.check_mode: - payload = ldap_federation.update() - else: - payload = ldap_federation.update(check=True) - if payload: - result['msg'] = to_text( - 'Federation {given_id} updated.'.format(given_id=ldap_federation.given_id) - ) - result['changed'] = True - else: - result['msg'] = to_text( - 'Federation {given_id} up to date, doing nothing.'.format( - given_id=ldap_federation.given_id - ) - ) - result['changed'] = False - - result['ldap_federation'] = payload - + module.fail_json(msg=str(e), changed=False, ldap_federation={}) module.exit_json(**result) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py index 0de74eb608a994..e6fbc6c714cf40 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -322,7 +322,7 @@ from ansible.module_utils.common.dict_transformations import recursive_diff, dict_merge -from ansible.module_utils._text import to_text +from ansible.module_utils.identity.keycloak.crud import crud_with_instance from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six.moves.urllib.parse import quote from ansible.module_utils.identity.keycloak.utils import ( @@ -354,7 +354,8 @@ class FederationGroupMapper(object): def __init__(self, module, connection_header): self.module = module self.restheaders = connection_header - self.description = 'mapper {}'.format(self.given_id) + self.uuid = self.module.params.get('mapper_uuid') + self.description = 'group mapper {}'.format(self.given_id) if self.module.params.get('mapper_name'): self.federation = LdapFederationBase(module, connection_header) if not self.federation.uuid: @@ -365,22 +366,11 @@ def __init__(self, module, connection_header): ) else: self.federation = None - self.representation = clean_payload_with_config( - get_on_url( - url=self._get_mapper_url(), - restheaders=self.restheaders, - module=self.module, - description=self.description, - ) - ) + self.initial_representation = self.representation try: - self.uuid = self.representation['id'] + self.uuid = self.initial_representation['id'] except KeyError: - self.uuid = '' - else: - if self.representation['providerId'] != 'group-ldap-mapper': - raise KeycloakError( - '{given_id} is not a group mapper.'.format(given_id=self.given_id)) + pass @property def given_id(self): @@ -397,25 +387,18 @@ def _get_mapper_url(self): """Create the url in order to get the federation from the given argument (uuid or name) :return: the url as string :rtype: str""" - try: + if self.uuid: return GROUP_MAPPER_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), - uuid=self.uuid, - ) - except AttributeError: - if self.module.params.get('mapper_name'): - return GROUP_MAPPER_BY_NAME.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - mapper_name=quote(self.module.params.get('mapper_name').lower()), - federation_uuid=self.federation.uuid, - ) - return GROUP_MAPPER_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=quote(self.module.params.get('mapper_uuid')), + uuid=quote(self.uuid), ) + return GROUP_MAPPER_BY_NAME.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + mapper_name=quote(self.module.params.get('mapper_name').lower()), + federation_uuid=self.federation.uuid, + ) def _create_payload(self): translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} @@ -452,7 +435,7 @@ def _create_payload(self): config.update({snake_to_point_case(key): [value]}) try: old_configuration = { - key: [value] for key, value in self.representation['config'].items() + key: [value] for key, value in self.initial_representation['config'].items() } except KeyError: old_configuration = {} @@ -528,7 +511,7 @@ def _arguments_update_representation(self): for key, value in clean_payload['config'].items(): if not value: payload_without_empty_values['config'].pop(key) - payload_diff, _ = recursive_diff(payload_without_empty_values, self.representation) + payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) try: config_diff = payload_diff.pop('config') except KeyError: @@ -567,6 +550,17 @@ def create(self, check=False): ) return clean_payload_with_config(payload) + @property + def representation(self): + return clean_payload_with_config( + get_on_url( + url=self._get_mapper_url(), + restheaders=self.restheaders, + module=self.module, + description=self.description, + ) + ) + def run_module(): argument_spec = keycloak_argument_spec() @@ -624,83 +618,14 @@ def run_module(): auth_password=module.params.get('auth_password'), client_secret=module.params.get('auth_client_secret'), ) - except KeycloakError as err: - module.fail_json(msg=str(err), changed=False, group_mapper={}) - try: federation_group_mapper = FederationGroupMapper(module, connection_header) - except KeycloakError as err: - module.fail_json(msg=str(err), changed=False, group_mapper={}) - waited_state = module.params.get('state') - try: - result = crud_group_mapper(federation_group_mapper, module, waited_state) + result = crud_with_instance(federation_group_mapper, 'group_mapper') except KeycloakError as err: module.fail_json(msg=str(err), changed=False, group_mapper={}) module.exit_json(**result) -def crud_group_mapper(federation_group_mapper, module, waited_state): - if waited_state == 'absent': - if federation_group_mapper.representation: - if not module.check_mode: - federation_group_mapper.delete() - result = { - 'changed': True, - 'msg': 'Group mapper {given_id} deleted.'.format( - given_id=federation_group_mapper.given_id - ), - 'group_mapper': {}, - } - else: - result = { - 'changed': False, - 'msg': 'Group mapper {given_id} does not exist, doing nothing.'.format( - given_id=federation_group_mapper.given_id - ), - 'group_mapper': federation_group_mapper.representation, - } - else: - if federation_group_mapper.representation: - if module.check_mode: - payload = federation_group_mapper.update(check=True) - else: - payload = federation_group_mapper.update() - if payload: - result = { - 'msg': to_text( - 'Group mapper {given_id} updated.'.format( - given_id=federation_group_mapper.given_id - ) - ), - 'changed': True, - 'group_mapper': payload, - } - else: - result = { - 'changed': False, - 'msg': '{} group mapper up to date, doing nothing.'.format( - federation_group_mapper.given_id - ), - 'group_mapper': federation_group_mapper.representation, - } - else: - if module.check_mode: - payload = federation_group_mapper.create(check=True) - else: - payload = federation_group_mapper.create() - result = { - 'msg': to_text( - 'Group mapper {given_id} created.'.format( - given_id=federation_group_mapper.given_id - ) - ), - 'changed': True, - 'group_mapper': payload, - } - - return result - - def main(): run_module() diff --git a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py index cf8e0223cd6c30..a08516e0fff8a9 100644 --- a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py +++ b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function + __metaclass__ = type import json @@ -265,7 +266,7 @@ def test_present_group_mapper_without_properties_should_do_nothing( given_id = extra_arguments['mapper_name'] except KeyError: given_id = extra_arguments['mapper_uuid'] - assert ansible_exit_json['msg'] == '{} group mapper up to date, doing nothing.'.format( + assert ansible_exit_json['msg'] == 'Group mapper {} up to date, doing nothing.'.format( given_id ) assert not ansible_exit_json['changed'] @@ -350,6 +351,10 @@ def test_state_absent_should_delete_existing_mapper(monkeypatch, mock_delete_url @pytest.fixture() def mock_update_url(mocker): existing_federation = deepcopy(CONNECTION_DICT) + updated_mapper = deepcopy(MAPPER_DICT) + updated_mapper['config']['groups.dn'] = ['ou=Group,dc=NewCompany,dc=io'] + updated_mapper['config']['groups.ldap.filter'] = [''] + updated_mapper['config']['mapped.group.attributes'] = [''] existing_federation.update( { 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( @@ -365,9 +370,10 @@ def mock_update_url(mocker): 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( json.dumps(MAPPER_DICT) ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( - json.dumps({}) - ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(updated_mapper)), + ], } ) return mocker.patch( @@ -421,6 +427,7 @@ def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_ur 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', } assert update_call_arguments == reference_arguments + reference_arguments['id'] = '123-123' assert ansible_exit_json['group_mapper'] == clean_payload_with_config(reference_arguments) @@ -479,6 +486,11 @@ def test_incompatible_arguments_should_fail( @pytest.fixture() def mock_create_url(mocker): + created_mapper = deepcopy(MAPPER_DICT) + created_mapper['config']['memberof.ldap.attribute'] = ['memberOf'] + created_mapper['config']['groups.ldap.filter'] = [''] + created_mapper['config']['mapped.group.attributes'] = [''] + created_mapper.pop('id') existing_federation = deepcopy(CONNECTION_DICT) existing_federation.update( { @@ -492,9 +504,10 @@ def mock_create_url(mocker): } ) ), - 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': create_wrapper( - json.dumps({}) - ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=group-ldap-mapper1': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(created_mapper)), + ], 'http://keycloak.url/auth/admin/realms/master/components': create_wrapper( json.dumps({}) ), @@ -518,7 +531,7 @@ def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url) 'realm': 'master', 'mapper_name': 'group-ldap-mapper1', 'federation_id': 'my-company-ldap', - 'groups_dn': 'ou=Group,dc=myCompany,dc=io', + 'groups_dn': 'ou=Group,dc=my-company,dc=io', } set_module_args(arguments) @@ -527,7 +540,7 @@ def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url) ansible_exit_json = ansible_stacktrace.value.args[0] reference_payload = { 'config': { - 'groups.dn': ['ou=Group,dc=myCompany,dc=io'], + 'groups.dn': ['ou=Group,dc=my-company,dc=io'], 'group.name.ldap.attribute': ['cn'], 'group.object.classes': ['groupOfNames'], 'preserve.group.inheritance': ['true'], @@ -582,7 +595,9 @@ def mock_does_not_exist_mapper(mocker): ) -def test_absent_state_and_mapper_does_not_exist_should_do_nothing(monkeypatch, mock_does_not_exist_mapper): +def test_absent_state_and_mapper_does_not_exist_should_do_nothing( + monkeypatch, mock_does_not_exist_mapper +): monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_group_mapper.AnsibleModule, 'fail_json', fail_json) arguments = { diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py index 08b1a59352c83e..02fde5bfaf9ecc 100644 --- a/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function + __metaclass__ = type @@ -64,26 +65,20 @@ 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 - ) + return get_response(object_with_future_response[method], method, get_id_call_count) if isinstance(object_with_future_response, list): try: call_number = get_id_call_count.__next__() except AttributeError: # manage python 2 versions. call_number = get_id_call_count.next() - return get_response( - object_with_future_response[call_number], method, get_id_call_count - ) + return get_response(object_with_future_response[call_number], method, get_id_call_count) return object_with_future_response def raise_404(url): def _raise_404(): - raise HTTPError( - url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('') - ) + raise HTTPError(url=url, code=404, msg='does not exist', hdrs='', fp=StringIO('')) return _raise_404 @@ -91,17 +86,19 @@ def _raise_404(): @pytest.fixture def mock_absent_url(mocker): absent_federation = deepcopy(CONNECTION_DICT) - absent_federation.update({ - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not_here': create_wrapper( - json.dumps([]) - ), - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not%20here': create_wrapper( - json.dumps([]) - ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': raise_404( - 'http://keycloak.url/auth/admin/realms/master/components/123-123' - ), - }) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not_here': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=not%20here': create_wrapper( + json.dumps([]) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': raise_404( + 'http://keycloak.url/auth/admin/realms/master/components/123-123' + ), + } + ) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), absent_federation), @@ -111,11 +108,7 @@ def mock_absent_url(mocker): @pytest.mark.parametrize( 'extra_arguments', - [ - {'federation_id': 'not_here'}, - {'federation_id': 'not here'}, - {'federation_uuid': '123-123'}, - ], + [{'federation_id': 'not_here'}, {'federation_id': 'not here'}, {'federation_uuid': '123-123'}], ) def test_state_absent_should_not_create_absent_federation( monkeypatch, mock_absent_url, extra_arguments @@ -136,9 +129,7 @@ def test_state_absent_should_not_create_absent_federation( with pytest.raises(AnsibleExitJson) as exec_error: keycloak_ldap_federation.run_module() ansible_exit_json = exec_error.value.args[0] - assert ansible_exit_json[ - 'msg' - ] == 'Federation {} does not exist, doing nothing.'.format( + assert ansible_exit_json['msg'] == 'Federation {} does not exist, doing nothing.'.format( list(extra_arguments.values())[0] ) assert not ansible_exit_json['changed'] @@ -151,33 +142,35 @@ def mock_delete_url(mocker): # with parts needed in the test and some value in order to have object # organisation. delete_federation = deepcopy(CONNECTION_DICT) - delete_federation.update({ - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=ldap-to-delete': create_wrapper( - json.dumps( - [ - { - 'id': '123-123', - 'name': 'ldap-to-delete', - 'parentId': 'master', - 'config': {'pagination': [True]}, - } - ] - ) - ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': { - 'DELETE': None, - 'GET': create_wrapper( + delete_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=ldap-to-delete': create_wrapper( json.dumps( - { - 'id': '123-123', - 'name': 'ldap-to-delete', - 'parentId': 'master', - 'config': {'pagination': [True]}, - } + [ + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ] ) ), - }, - }) + 'http://keycloak.url/auth/admin/realms/master/components/123-123': { + 'DELETE': None, + 'GET': create_wrapper( + json.dumps( + { + 'id': '123-123', + 'name': 'ldap-to-delete', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + }, + } + ) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), delete_federation), @@ -186,8 +179,7 @@ def mock_delete_url(mocker): @pytest.mark.parametrize( - 'extra_arguments', - [{'federation_id': 'ldap-to-delete'}, {'federation_uuid': '123-123'}], + 'extra_arguments', [{'federation_id': 'ldap-to-delete'}, {'federation_uuid': '123-123'}] ) def test_state_absent_should_delete_existing_federation( monkeypatch, extra_arguments, mock_delete_url @@ -218,12 +210,46 @@ def test_state_absent_should_delete_existing_federation( @pytest.fixture() def mock_create_url(mocker): create_federation = deepcopy(CONNECTION_DICT) - create_federation.update({ - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps([]) - ), - 'http://keycloak.url/auth/admin/realms/master/components/': None, - }) + create_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': [ + create_wrapper(json.dumps([])), + create_wrapper( + json.dumps( + { + 'config': { + 'bindDn': ['cn=admin,dc=my-company'], + 'connectionPooling': [False], + 'connectionUrl': ['ldap://openldap'], + 'bindCredential': ['no_log'], + 'editMode': ['WRITABLE'], + 'priority': [0], + 'rdnLDAPAttribute': ['cn'], + 'searchScope': [2], + 'syncRegistrations': [True], + 'useTruststoreSpi': ['never'], + 'userObjectClasses': ['inetOrgPerson, organizationalPerson'], + 'usernameLDAPAttribute': ['cn'], + 'usersDn': ['ou=People,dc=my-company'], + 'uuidLDAPAttribute': ['entryUUID'], + 'vendor': ['other'], + 'cachePolicy': ['DEFAULT'], + 'evictionDay': [None], + 'evictionHour': [None], + 'evictionMinute': [None], + 'maxLifespan': [None], + 'batchSizeForSync': [1000], + }, + 'name': 'company-ldap', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + } + ) + ), + ], + 'http://keycloak.url/auth/admin/realms/master/components/': None, + } + ) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), create_federation), @@ -231,9 +257,7 @@ def mock_create_url(mocker): ) -def test_state_present_should_create_absent_federation( - monkeypatch, mock_create_url -): +def test_state_present_should_create_absent_federation(monkeypatch, mock_create_url): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) arguments = { @@ -335,9 +359,7 @@ def test_create_payload_all_mandatory(monkeypatch, mock_absent_url): @pytest.fixture() def mock_check_connectivity(mocker): - check_connectivity = { - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, - } + check_connectivity = {'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None} return mocker.patch( 'ansible.modules.identity.keycloak.keycloak_ldap_federation.open_url', side_effect=build_mocked_request(count(), check_connectivity), @@ -399,27 +421,26 @@ def _raise_400(): @pytest.fixture() def mock_wrong_authentication_url(mocker, request): create_federation = deepcopy(CONNECTION_DICT) - create_federation.update({ - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps([]) - ), - }) + create_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps([]) + ) + } + ) ldap_connection = { 'wrong LDAP address': raise_400( 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection' ), 'wrong credentials': [ None, - raise_400( - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection' - ), + raise_400('http://keycloak.url/auth/admin/realms/master/testLDAPConnection'), ], } check_connection = { - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': ldap_connection[ request.node.callspec.id - ], + ] } mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', @@ -459,10 +480,7 @@ def mock_wrong_authentication_url(mocker, request): ids=['wrong LDAP address', 'wrong credentials'], ) def test_wrong_ldap_credentials_should_raise_an_error( - monkeypatch, - extra_arguments, - waited_message, - mock_wrong_authentication_url, + monkeypatch, extra_arguments, waited_message, mock_wrong_authentication_url ): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) @@ -493,40 +511,72 @@ def test_wrong_ldap_credentials_should_raise_an_error( @pytest.fixture() def mock_update_url(mocker): update_federation = deepcopy(CONNECTION_DICT) - update_federation.update({ - 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( - json.dumps( - [ - { - 'id': '123-123', - 'name': 'company-ldap', - 'parentId': 'master', - 'providerId': 'ldap', - 'providerType': 'org.keycloak.storage.UserStorageProvider', - 'config': { - 'pagination': [True], - 'bindDn': ['cn:admin'], - 'batchSizeForSync': [1000], - 'fullSyncPeriod': [-1], - 'changedSyncPeriod': [-1], - 'evictionDay': [], - 'evictionHour': [], - 'evictionMinute': [], - 'maxLifespan': [], - 'customUserSearchFilter': [], - 'connectionPooling': [False], - 'syncRegistrations': [False], - 'priority': [3], - 'cachePolicy': ['DEFAULT'], - 'uuidLDAPAttribute': ['entryUUID'], - }, - } - ] - ) - ), - 'http://keycloak.url/auth/admin/realms/master/components/123-123': None, - 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, - }) + update_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=company-ldap': create_wrapper( + json.dumps( + [ + { + 'id': '123-123', + 'name': 'company-ldap', + 'parentId': 'master', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + 'config': { + 'pagination': [True], + 'bindDn': ['cn:admin'], + 'batchSizeForSync': [1000], + 'fullSyncPeriod': [-1], + 'changedSyncPeriod': [-1], + 'evictionDay': [], + 'evictionHour': [], + 'evictionMinute': [], + 'maxLifespan': [], + 'customUserSearchFilter': [], + 'connectionPooling': [False], + 'syncRegistrations': [False], + 'priority': [3], + 'cachePolicy': ['DEFAULT'], + 'uuidLDAPAttribute': ['entryUUID'], + }, + } + ] + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': [ + None, + create_wrapper( + json.dumps( + { + 'id': '123-123', + 'name': 'company-ldap', + 'parentId': 'master', + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + 'config': { + 'pagination': [True], + 'bindDn': ['cn:admin'], + 'batchSizeForSync': [1000], + 'fullSyncPeriod': [-1], + 'changedSyncPeriod': [-1], + 'evictionDay': [], + 'evictionHour': [], + 'evictionMinute': [], + 'maxLifespan': [], + 'customUserSearchFilter': [], + 'connectionPooling': [False], + 'syncRegistrations': [False], + 'priority': [3], + 'cachePolicy': ['DEFAULT'], + 'uuidLDAPAttribute': ['newEntryUUID'], + }, + } + ) + ), + ], + 'http://keycloak.url/auth/admin/realms/master/testLDAPConnection': None, + } + ) return mocker.patch( 'ansible.module_utils.identity.keycloak.keycloak.open_url', side_effect=build_mocked_request(count(), update_federation), @@ -534,9 +584,7 @@ def mock_update_url(mocker): ) -def test_state_present_should_update_existing_federation( - monkeypatch, mock_update_url -): +def test_state_present_should_update_existing_federation(monkeypatch, mock_update_url): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) arguments = { @@ -576,14 +624,14 @@ def test_state_present_should_update_existing_federation( 'name': 'company-ldap', 'providerId': 'ldap', 'providerType': 'org.keycloak.storage.UserStorageProvider', + 'id': '123-123', + 'parentId': 'master', } diff_result = recursive_diff(ansible_exit_json['ldap_federation'], reference_result) assert not diff_result -def test_same_values_should_not_update_an_existing_federation( - monkeypatch, mock_update_url -): +def test_same_values_should_not_update_an_existing_federation(monkeypatch, mock_update_url): monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'exit_json', exit_json) monkeypatch.setattr(keycloak_ldap_federation.AnsibleModule, 'fail_json', fail_json) arguments = { @@ -629,9 +677,7 @@ def test_state_present_should_update_existing_federation_with_connect_check( assert urlencode({'bindDn': 'cn:admin'}) in send_data -def test_create_payload_for_synchronization( - monkeypatch, mock_update_url, mock_check_connectivity -): +def test_create_payload_for_synchronization(monkeypatch, mock_update_url, mock_check_connectivity): """When updating the sync registration of a federation, the payload needs to have some keys. If not, the response is 204 put the sync registration parameter is not updated.""" From 86b3c82540bcb57a57911e5dd2e09a82e6146490 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Wed, 18 Sep 2019 14:39:04 +0200 Subject: [PATCH 74/79] feature: CRUD for keycloak ldap role mapper --- .../module_utils/identity/keycloak/urls.py | 2 + .../module_utils/identity/keycloak/utils.py | 1 + .../keycloak/keycloak_ldap_federation.py | 4 +- .../keycloak/keycloak_ldap_group_mapper.py | 23 +- .../keycloak/keycloak_ldap_role_mapper.py | 610 +++++++++++++++++ .../test_kecyloak_ldap_group_mapper.py | 2 +- .../test_keycloak_ldap_role_mapper.py | 641 ++++++++++++++++++ 7 files changed, 1271 insertions(+), 12 deletions(-) create mode 100644 lib/ansible/module_utils/identity/keycloak/urls.py create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_ldap_role_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/urls.py b/lib/ansible/module_utils/identity/keycloak/urls.py new file mode 100644 index 00000000000000..f628f90525af51 --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/urls.py @@ -0,0 +1,2 @@ +COMPONENTS_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' +MAPPER_BY_NAME = '{url}/admin/realms/{realm}/components?parent={federation_uuid}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name={mapper_name}' \ No newline at end of file diff --git a/lib/ansible/module_utils/identity/keycloak/utils.py b/lib/ansible/module_utils/identity/keycloak/utils.py index d0721f26444108..70f5f89cf50c1c 100644 --- a/lib/ansible/module_utils/identity/keycloak/utils.py +++ b/lib/ansible/module_utils/identity/keycloak/utils.py @@ -1,6 +1,7 @@ # Copyright: (c) 2018, Nicolas Duclert # 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 copy import deepcopy diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 6aec19815a7ebb..d8ad530d060448 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -574,6 +574,7 @@ LdapFederationBase, COMPONENTS_URL, ) +from ansible.module_utils.identity.keycloak.urls import COMPONENTS_BY_UUID_URL from ansible.module_utils.identity.keycloak.utils import ( clean_payload_with_config, if_absent_add_a_default_value, @@ -585,7 +586,6 @@ from ansible.module_utils.six.moves.urllib.error import HTTPError USER_FEDERATION_URL = '{url}/admin/realms/{realm}/components?parent={realm}&type=org.keycloak.storage.UserStorageProvider&name={federation_id}' -USER_FEDERATION_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' TEST_LDAP_CONNECTION = '{url}/admin/realms/{realm}/testLDAPConnection' SEARCH_SCOPE = {'one level': 1, 'subtree': 2} @@ -622,7 +622,7 @@ def update(self, check=False): federation_payload = self._create_payload() if check: return clean_payload_with_config(federation_payload) - put_url = USER_FEDERATION_BY_UUID_URL.format( + put_url = COMPONENTS_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), uuid=self.uuid, diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py index e6fbc6c714cf40..27b10001590fa6 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -31,8 +31,8 @@ state: description: - State of the LDAP federation. - - On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide. - - On C(absent), the group will be removed if it exists. + - On C(present), the group mapper will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the group mapper will be removed if it exists. required: true default: present type: str @@ -43,7 +43,7 @@ realm: type: str description: - - They Keycloak realm under which this LDAP federation resides. + - They Keycloak realm under which this LDAP group mapper resides. default: 'master' federation_id: @@ -338,15 +338,16 @@ put_on_url, post_on_url, ) +from ansible.module_utils.identity.keycloak.urls import ( + COMPONENTS_BY_UUID_URL, + MAPPER_BY_NAME, +) from ansible.module_utils.identity.keycloak.utils import snake_to_point_case, convert_to_bool from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( LdapFederationBase, COMPONENTS_URL, ) -GROUP_MAPPER_BY_UUID_URL = '{url}/admin/realms/{realm}/components/{uuid}' -GROUP_MAPPER_BY_NAME = '{url}/admin/realms/{realm}/components?parent={federation_uuid}&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name={mapper_name}' - USER_GROUP_RETRIEVE_STRATEGY_LABEL = 'user.roles.retrieve.strategy' @@ -371,6 +372,10 @@ def __init__(self, module, connection_header): self.uuid = self.initial_representation['id'] except KeyError: pass + else: + if self.initial_representation['providerId'] != 'group-ldap-mapper': + raise KeycloakError( + '{given_id} is not a group mapper.'.format(given_id=self.given_id)) @property def given_id(self): @@ -388,12 +393,12 @@ def _get_mapper_url(self): :return: the url as string :rtype: str""" if self.uuid: - return GROUP_MAPPER_BY_UUID_URL.format( + return COMPONENTS_BY_UUID_URL.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), uuid=quote(self.uuid), ) - return GROUP_MAPPER_BY_NAME.format( + return MAPPER_BY_NAME.format( url=self.module.params.get('auth_keycloak_url'), realm=quote(self.module.params.get('realm')), mapper_name=quote(self.module.params.get('mapper_name').lower()), @@ -499,7 +504,7 @@ def _check_arguments(new_configuration): else: if ldap_filter[0] != '(' and ldap_filter[-1] != ')': raise KeycloakError( - 'LDAP filter should begin with a opening bracket and end with closing braket.' + 'LDAP filter should begin with a opening bracket and end with closing bracket.' ) def delete(self): diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py new file mode 100644 index 00000000000000..4275045eb95882 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py @@ -0,0 +1,610 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +from ansible.module_utils.identity.keycloak.urls import COMPONENTS_BY_UUID_URL, MAPPER_BY_NAME +from ansible.module_utils.identity.keycloak.utils import ( + clean_payload_with_config, + snake_to_point_case, + if_absent_add_a_default_value, +) + +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: keycloak_ldap_role_mapper + +short_description: Allows administration of Keycloak LDAP role mapper via Keycloak API +description: + - This module allows you to add, remove or modify Keycloak LDAP role mapper via the Keycloak 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. + + - When updating a LDAP role mapper federation, where possible provide the mapper ID to the + module. This removes a lookup to the API to translate the name into the mapper ID. + + - This module has been tested against keycloak 6.0, the backward compatibility is not guaranteed. + +version_added: "2.10" +options: + state: + description: + - State of the LDAP federation mapper. + - On C(present), the role mapper will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the role mapper will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP role mapper resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federation in the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with federation_uuid and one + of them is required by the module + type: str + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with federation_id and one + of them is required by the module + type: str + + mapper_name: + description: + - The name of the role mapper + - This parameter is mutually exclusive with mapper_uuid and one + of them is required by the module + type: str + + mapper_uuid: + description: + - The uuid of the role mapper + - This parameter is mutually exclusive with mapper_name and one + of them is required by the module + type: str + + roles_dn: + description: + - LDAP DN where are roles of this tree saved + - This parameter is mandatory when creating a new LDAP role mapper + type: str + + role_name_ldap_attribute: + description: + - Name of LDAP attribute, which is used in group objects for name and RDN of role + type: str + + role_object_classes: + description: + - List of class of the role object + type: list + + membership_ldap_attribute: + description: + - Name of LDAP attribute on role, which is used for membership mappings + type: str + + membership_attribute_type: + description: + - Describe the way the members of the group are declared + - C(DN): LDAP role has it's members declared in form of their full DN, + - C(UID): LDAP role has it's members declared in form of pure user uids + choices: + - DN + - UID + type: str + + membership_user_ldap_attribute: + description: + - Used just if I(membership_attribute_type=UID) + - It is name of LDAP attribute on user, which is used for membership mappings + type: str + + groups_ldap_filter: + description: + - LDAP Filter adds additional custom filter to the whole query for retrieve LDAP roles + - Filter must start with '(' and ends with ')' + type: str + + mode: + description: + - Select the way role will be created or not in the Keycloak database + - C(LDAP_ONLY): all role mappings of users are retrieved from LDAP and saved into LDAP + - C(READ_ONLY): read-only LDAP mode where role mappings are retrieved from both LDAP and + DB and merged together. New role joins are not saved to LDAP but to DB. + - C(IMPORT): read-only LDAP mode where role mappings are retrieved from LDAP just at the + time when user is imported from LDAP and then they are saved to local keycloak DB. + choices: + - LDAP_ONLY + - READ_ONLY + - IMPORT + type: str + + user_roles_retrieve_strategy: + description: + - Specify how to retrieve roles of user + - C(LOAD_ROLES_BY_MEMBER_ATTRIBUTE): roles of user will be retrieved by sending LDAP + query to retrieve all roles where 'member' is our user + - C(GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE): roles of user will be retrieved from + memberOf attribute of our user. Or from the other attribute specified by + I(member_of_ldap_attribute) + - C(LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY): applicable just in Active Directory and it + means that roles of user will be retrieved recursively with usage of + LDAP_MATCHING_RULE_IN_CHAIN Ldap extension. + choices: + - LOAD_ROLES_BY_MEMBER_ATTRIBUTE + - GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE + - LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY + + memberof_ldap_attribute: + description: + - Used just when I(user_roles_retrieve_strategy=GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE) + - It specifies the name of the LDAP attribute on the LDAP user, which contains the roles, + which the user is member of + type: str + + use_realm_roles_mapping: + description: + - If true, then LDAP role mappings will be mapped to realm role mappings in Keycloak. + Otherwise it will be mapped to client role mappings + type: str + + client_id: + description: + - Client ID of client to which LDAP role mappings will be mapped + - Applicable just if I(use_realm_roles_mapping=no) + +extends_documentation_fragment: + - keycloak + +author: + - Nicolas Duclert (@ndclt) +''' + +EXAMPLES = r''' +- name: Minimal creation + keycloak_ldap_role_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + federation_id: my-company-ldap + mapper_name: role-mapper-for-company + roles_dn: ou=Group,dc=MyCompany +- name: Update with all parameters + keycloak_ldap_role_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + federation_id: my-company-ldap + mapper_name: role-mapper-for-company + roles_dn: ou=NewGroup,dc=MyCompany + role_name_ldap_attribute: dn + role_object_classes: + - OneClass + - AnotherRoleCLass + membership_ldap_attribute: plop + membership_attribute_type: UID + membership_user_ldap_attribute: attribute + roles_ldap_filter: (anicefilter) + mode: READ_ONLY + user_roles_retrieve_strategy: GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE + memberof_ldap_attribute: a + use_realm_roles_mapping: no + client_id: admin-cli +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "role mapper my-group-mapper created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +group_mapper: + description: the LDAP role mapper representation. Empty if the asked role mapper is deleted or does not exist. + returned: always + type: dict + contains: + name: + description: name of the LDAP role mapper + type: str + returned: on success + sample: group-ldap-mapper1 + providerId: + description: the id of the role mapper, always C(role-ldap-mapper) for this module + type: str + returned: on success + sample: role-ldap-mapper + parentId: + description: the LDAP role mapper parent uuid + type: str + returned: on success + sample: de455375-6900-46a0-8d11-51554e1c3f18 + providerType: + description: the type of the object, for this module always C(org.keycloak.storage.ldap.mappers.LDAPStorageMapper) + type: str + returned: on success + sample: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + config: + description: the configuration of the LDAP role mapper + type: dict + returned: on success + contains: + roles.dn: + description: LDAP DN where are roles of this tree saved + type: str + returned: on success + sample: ou=Role,dc=NewCompany + role.name.ldap.attribute: + description: Name of LDAP attribute, which is used in role objects for name and RDN of role + type: str + returned: on success + sample: cn + role.object.classes: + description: List of class of the role object + type: str + returned: on success + sample: roleOfNames + membership.ldap.attribute: + description: specifies the name of the LDAP attribute on the LDAP user + type: str + returned: on success + sample: memberOf + membership.attribute.type: + description: Describe the way the members of the role are declared + type: str + returned: on success + sample: DN + membership.user.ldap.attribute: + description: specifies the name of the LDAP attribute on the LDAP user + type: str + returned: on success + sample: memberOf + groups.ldap.filter: + description: LDAP Filter adds additional custom filter to the whole query for retrieve LDAP roles + type: str + returned: on success + sample: (roleType=2147483652) + mode: + description: Select the way group will be created or not in the Keycloak database + type: str + returned: on success + sample: READ_ONLY + user.roles.retrieve.strategy: + description: Specify how to retrieve roles of user + type: str + returned: on success + sample: LOAD_GROUPS_BY_MEMBER_ATTRIBUTE + memberof.ldap.attribute: + description: specifies the name of the LDAP attribute on the LDAP user + type: str + returned: on success + sample: memberOf + use.realm.roles.mapping: + description: If true, then LDAP role mappings will be mapped to realm role mappings in Keycloak. Otherwise it will be mapped to client role mappings + type: str + returned: on success + sample: true + client.id: + description: Client ID of client to which LDAP role mappings will be mapped. Applicable just if use.realm.roles.mapping is False. + type: str + returned: on success + sample: admin-cli +''' + +from copy import deepcopy + +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakError, + get_on_url, + keycloak_argument_spec, + get_token, + put_on_url, + delete_on_url, + post_on_url, +) +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( + LdapFederationBase, + COMPONENTS_URL, +) +from ansible.module_utils.six.moves.urllib.parse import quote +from ansible.module_utils.common.dict_transformations import recursive_diff, dict_merge + +from ansible.module_utils.identity.keycloak.crud import crud_with_instance +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI + + +class FederationRoleMapper(object): + def __init__(self, module, connection_header): + self.module = module + self.restheaders = connection_header + self.uuid = self.module.params.get('mapper_uuid') + self.description = 'role mapper {}'.format(self.given_id) + if self.module.params.get('mapper_name'): + self.federation = LdapFederationBase(module, connection_header) + if not self.federation.uuid: + raise KeycloakError( + 'Cannot access mapper because {given_id} federation does not exist.'.format( + given_id=self.federation.given_id + ) + ) + else: + self.federation = None + self.initial_representation = self.representation + try: + self.uuid = self.initial_representation['id'] + except KeyError: + pass + else: + if self.initial_representation['providerId'] != 'role-ldap-mapper': + raise KeycloakError( + '{given_id} is not a role mapper.'.format(given_id=self.given_id) + ) + + @property + def given_id(self): + """Get the asked mapper id given by the user. + + :return the asked id given by the user as a name or an uuid. + :rtype: str + """ + if self.module.params.get('mapper_name'): + return self.module.params.get('mapper_name') + return self.module.params.get('mapper_uuid') + + @property + def representation(self): + return clean_payload_with_config( + get_on_url( + url=self._get_mapper_url(), + restheaders=self.restheaders, + module=self.module, + description=self.description, + ) + ) + + def _get_mapper_url(self): + """Create the url in order to get the federation from the given argument (uuid or name) + :return: the url as string + :rtype: str""" + if self.uuid: + return COMPONENTS_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=quote(self.uuid), + ) + return MAPPER_BY_NAME.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + mapper_name=quote(self.module.params.get('mapper_name').lower()), + federation_uuid=self.federation.uuid, + ) + + def _arguments_update_representation(self): + clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) + payload_without_empty_values = deepcopy(clean_payload) + for key, value in clean_payload['config'].items(): + if not value: + payload_without_empty_values['config'].pop(key) + payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) + try: + config_diff = payload_diff.pop('config') + except KeyError: + config_diff = {} + if not payload_diff and not config_diff: + return False + return True + + def update(self, check=False): + if not self._arguments_update_representation(): + return {} + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + put_on_url( + self._get_mapper_url(), self.restheaders, self.module, self.description, payload + ) + return clean_payload_with_config(payload) + + def _create_payload(self): + translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} + config = {} + payload = { + 'providerId': 'role-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + not_mapper_argument = list(keycloak_argument_spec().keys()) + [ + 'state', + 'realm', + 'federation_id', + ] + if self.federation: + payload.update({'parentId': self.federation.uuid}) + for key, value in self.module.params.items(): + if value is not None and key not in not_mapper_argument: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + elif key == 'role_object_classes': + config.update({snake_to_point_case(key): [','.join(value)]}) + else: + config.update({snake_to_point_case(key): [value]}) + try: + old_configuration = { + key: [value] for key, value in self.initial_representation['config'].items() + } + except KeyError: + old_configuration = {} + new_configuration = dict_merge(old_configuration, config) + self._check_arguments(new_configuration) + dict_of_default = { + 'role.name.ldap.attribute': 'cn', + 'role.object.classes': 'groupOfNames', + 'membership.ldap.attribute': 'member', + 'membership.attribute.type': 'DN', + 'membership.user.ldap.attribute': 'cn', + 'mode': 'LDAP_ONLY', + 'user.roles.retrieve.strategy': 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE', + 'memberof.ldap.attribute': 'memberOf', + 'use.realm.roles.mapping': 'true', + } + payload.update( + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} + ) + return payload + + def _check_arguments(self, new_configuration): + try: + ldap_filter = new_configuration[snake_to_point_case('roles_ldap_filter')][0].strip() + except KeyError: + pass + else: + if ldap_filter[0] != '(' and ldap_filter[-1] != ')': + raise KeycloakError( + 'LDAP filter should begin with a opening bracket and end with closing bracket.' + ) + try: + client_id = new_configuration[snake_to_point_case('client_id')][0] + except KeyError: + pass + else: + keycloak_api = KeycloakAPI(self.module, self.restheaders) + client_ids = [ + client['clientId'] + for client in keycloak_api.get_clients(self.module.params.get('realm')) + ] + if client_id and client_id not in client_ids: + raise KeycloakError( + 'Client {client_id} does not exist in the realm and cannot be used.'.format( + client_id=client_id + ) + ) + + def delete(self): + delete_on_url(self._get_mapper_url(), self.restheaders, self.module, self.description) + + def create(self, check=False): + if not self.module.params.get('roles_dn'): + raise KeycloakError('roles_dn is mandatory for role mapper creation.') + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + post_url = COMPONENTS_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + ) + post_on_url( + url=post_url, + restheaders=self.restheaders, + module=self.module, + description=self.description, + representation=payload, + ) + return clean_payload_with_config(payload) + + +def run_module(): + meta_args = dict( + realm=dict(type='str', default='master'), + federation_id=dict(type='str'), + federation_uuid=dict(type='str'), + mapper_name=dict(type='str'), + mapper_uuid=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']), + roles_dn=dict(type='str'), + role_name_ldap_attribute=dict(type='str'), + role_object_classes=dict(type='list'), + membership_ldap_attribute=dict(type='str'), + membership_attribute_type=dict(type='str', choices=['DN', 'UID']), + membership_user_ldap_attribute=dict(type='str'), + roles_ldap_filter=dict(type='str'), + mode=dict(type='str', choices=['LDAP_ONLY', 'IMPORT', 'READ_ONLY']), + user_roles_retrieve_strategy=dict( + type='str', + choices=[ + 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE', + 'GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE', + 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY', + ], + ), + memberof_ldap_attribute=dict( + type=str + ), # used only if user roles retrieve strategy is GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE + use_realm_roles_mapping=dict(type=bool), + # the value should be in the list of client id of the realm (to check) + client_id=dict(type=str), # used only if user realm roles mapping is False. + ) + argument_spec = keycloak_argument_spec() + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['mapper_name', 'mapper_uuid']], + mutually_exclusive=[['mapper_name', 'mapper_uuid']], + ) + if module.params.get('mapper_name') and not ( + module.params.get('federation_id') or module.params.get('federation_uuid') + ): + module.fail_json( + msg='With mapper name, the federation_id or federation_uuid must be given.', + changed=False, + role_mapper={}, + ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + role_mapper = FederationRoleMapper(module, connection_header) + result = crud_with_instance(role_mapper, 'role_mapper') + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, role_mapper={}) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py index a08516e0fff8a9..a14e8455030110 100644 --- a/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py +++ b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py @@ -450,7 +450,7 @@ def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_ur ), ( {'groups_ldap_filter': 'without parenthesis'}, - 'LDAP filter should begin with a opening bracket and end with closing braket.', + 'LDAP filter should begin with a opening bracket and end with closing bracket.', ), ], ids=[ diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_role_mapper.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_role_mapper.py new file mode 100644 index 00000000000000..52fd2ba9893195 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_role_mapper.py @@ -0,0 +1,641 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config + +__metaclass__ = type + +from ansible.module_utils.six import StringIO + +import json +from copy import deepcopy +from itertools import count + +import pytest +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) +from ansible.modules.identity.keycloak import keycloak_ldap_role_mapper + +MAPPER_DICT = { + 'id': '123-123', + 'name': 'role-ldap-mapper1', + 'providerId': 'role-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'mode': ['LDAP_ONLY'], + 'membership.attribute.type': ['DN'], + 'user.roles.retrieve.strategy': ['LOAD_ROLES_BY_MEMBER_ATTRIBUTE'], + 'roles.dn': ['ou=oneRole,dc=my-company'], + 'membership.user.ldap.attribute': ['cn'], + 'membership.ldap.attribute': ['member'], + 'role.name.ldap.attribute': ['cn'], + 'memberof.ldap.attribute': ['memberOf'], + 'use.realm.roles.mapping': ['true'], + 'role.object.classes': ['groupOfNames'], + }, +} + +WRONG_TYPE_MAPPER_DICT = deepcopy(MAPPER_DICT) +WRONG_TYPE_MAPPER_DICT.update({'providerId': 'group-ldap-mapper'}) + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def test_mapper_name_without_federation_id_should_fail(monkeypatch): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'With mapper name, the federation_id or federation_uuid must be given.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['role_mapper'] + + +@pytest.fixture +def mock_absent_federation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=does-not-exist': create_wrapper( + json.dumps([]) + ) + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_federation_does_not_exist_fail(monkeypatch, mock_absent_federation_url): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'does-not-exist', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'Cannot access mapper because does-not-exist federation does not exist.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['role_mapper'] + + +@pytest.fixture() +def mock_wrong_type(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=role-ldap-mapper1': create_wrapper( + json.dumps(WRONG_TYPE_MAPPER_DICT) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_good_name_but_wrong_type_should_raise_an_error(monkeypatch, mock_wrong_type): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'my-company-ldap', + 'mapper_name': 'role-ldap-mapper1', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'role-ldap-mapper1 is not a role mapper.' + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['role_mapper'] + + +@pytest.fixture() +def mock_existing_mapper(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=role-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps(MAPPER_DICT) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'mapper_name': 'role-ldap-mapper1', 'federation_id': 'my-company-ldap'}, + {'mapper_uuid': '123-123'}, + ], + ids=['mapper and federation names', 'mapper uuid'], +) +def test_present_group_mapper_without_properties_should_do_nothing( + monkeypatch, extra_arguments, mock_existing_mapper +): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + try: + given_id = extra_arguments['mapper_name'] + except KeyError: + given_id = extra_arguments['mapper_uuid'] + assert ansible_exit_json['msg'] == 'Role mapper {} up to date, doing nothing.'.format(given_id) + assert not ansible_exit_json['changed'] + assert ansible_exit_json['role_mapper'] == { + 'id': '123-123', + 'name': 'role-ldap-mapper1', + 'providerId': 'role-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'mode': 'LDAP_ONLY', + 'membership.attribute.type': 'DN', + 'user.roles.retrieve.strategy': 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE', + 'roles.dn': 'ou=oneRole,dc=my-company', + 'membership.user.ldap.attribute': 'cn', + 'membership.ldap.attribute': 'member', + 'role.name.ldap.attribute': 'cn', + 'memberof.ldap.attribute': 'memberOf', + 'use.realm.roles.mapping': 'true', + 'role.object.classes': 'groupOfNames', + }, + } + + +@pytest.fixture() +def mock_delete_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=role-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_absent_should_delete_existing_mapper(monkeypatch, mock_delete_url): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'role-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'state': 'absent', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Role mapper role-ldap-mapper1 deleted.' + assert ansible_exit_json['changed'] + assert not ansible_exit_json['role_mapper'] + delete_call = mock_delete_url.mock_calls[3] + assert delete_call[2]['method'] == 'DELETE' + + +@pytest.fixture() +def mock_update_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + updated_mapper = deepcopy(MAPPER_DICT) + updated_mapper['config']['roles.dn'] = ['ou=Role,dc=NewCompany,dc=io'] + updated_mapper['config']['role.name.ldap.attribute'] = ['bn'] + updated_mapper['config']['role.object.classes'] = ['Plop,Glop'] + updated_mapper['config']['membership.ldap.attribute'] = ['InvisibleMember'] + updated_mapper['config']['membership.attribute.type'] = ['UID'] + updated_mapper['config']['membership.user.ldap.attribute'] = ['bn'] + updated_mapper['config']['roles.ldap.filter'] = ['(abc)'] + updated_mapper['config']['mode'] = ['READ_ONLY'] + updated_mapper['config']['user.roles.retrieve.strategy'] = [ + 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY' + ] + updated_mapper['config']['memberof.ldap.attribute'] = ['InvisibleMemberOf'] + updated_mapper['config']['use.realm.roles.mapping'] = ['true'] + updated_mapper['config']['client.id'] = [''] + updated_mapper['id'] = '123-123' + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=role-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(updated_mapper)), + ], + 'http://keycloak.url/auth/admin/realms/master/clients': create_wrapper( + json.dumps( + # This is not the full return dictionary, there is only the used key. + [{'clientId': 'admin-cli'}, {'clientId': 'broker'}, {'clientId': 'account'}] + ) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_url): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'role-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'roles_dn': 'ou=Role,dc=NewCompany,dc=io', + 'role_name_ldap_attribute': 'bn', + 'role_object_classes': ['Plop', 'Glop'], + 'membership_ldap_attribute': 'InvisibleMember', + 'membership_attribute_type': 'UID', + 'membership_user_ldap_attribute': 'bn', + 'roles_ldap_filter': '(abc)', + 'mode': 'READ_ONLY', + 'user_roles_retrieve_strategy': 'LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY', + 'memberof_ldap_attribute': 'InvisibleMemberOf', + 'use_realm_roles_mapping': True, + 'client_id': '', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Role mapper role-ldap-mapper1 updated.' + assert ansible_exit_json['changed'] + update_call_arguments = json.loads(mock_update_url.mock_calls[5][2]['data']) + reference_arguments = { + 'name': 'role-ldap-mapper1', + 'providerId': 'role-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'roles.dn': ['ou=Role,dc=NewCompany,dc=io'], + 'role.name.ldap.attribute': ['bn'], + 'role.object.classes': ['Plop,Glop'], + 'membership.ldap.attribute': ['InvisibleMember'], + 'membership.attribute.type': ['UID'], + 'membership.user.ldap.attribute': ['bn'], + 'roles.ldap.filter': ['(abc)'], + 'mode': ['READ_ONLY'], + 'user.roles.retrieve.strategy': ['LOAD_ROLES_BY_MEMBER_ATTRIBUTE_RECURSIVELY'], + 'memberof.ldap.attribute': ['InvisibleMemberOf'], + 'use.realm.roles.mapping': [True], + 'client.id': [''], + }, + } + assert update_call_arguments == reference_arguments + reference_arguments['id'] = '123-123' + reference_arguments['config']['use.realm.roles.mapping'] = ['true'] + assert ansible_exit_json['role_mapper'] == clean_payload_with_config(reference_arguments) + + +@pytest.mark.parametrize( + 'extra_arguments, waited_error', + [ + ( + {'roles_ldap_filter': 'without parenthesis'}, + 'LDAP filter should begin with a opening bracket and end with closing bracket.', + ), + ( + {'client_id': 'does-not-exist-in-realm'}, + 'Client does-not-exist-in-realm does not exist in the realm and cannot be used.', + ), + ], + ids=['LDAP filter checks', 'Client does not exist'], +) +def test_incompatible_arguments_should_fail( + monkeypatch, mock_update_url, extra_arguments, waited_error +): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'role-ldap-mapper1', + 'federation_id': 'my-company-ldap', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['role_mapper'] + assert ansible_exit_json['msg'] == waited_error + + +@pytest.fixture() +def mock_create_url(mocker): + created_mapper = deepcopy(MAPPER_DICT) + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=role-ldap-mapper1': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(created_mapper)), + ], + 'http://keycloak.url/auth/admin/realms/master/components': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'role-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'roles_dn': 'ou=oneRole,dc=my-company', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + reference_payload = { + 'config': { + 'roles.dn': ['ou=oneRole,dc=my-company'], + 'role.name.ldap.attribute': ['cn'], + 'role.object.classes': ['groupOfNames'], + 'membership.ldap.attribute': ['member'], + 'membership.attribute.type': ['DN'], + 'membership.user.ldap.attribute': ['cn'], + 'mode': ['LDAP_ONLY'], + 'user.roles.retrieve.strategy': ['LOAD_ROLES_BY_MEMBER_ATTRIBUTE'], + 'memberof.ldap.attribute': ['memberOf'], + 'use.realm.roles.mapping': ['true'], + }, + 'name': 'role-ldap-mapper1', + 'providerId': 'role-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + } + + assert ansible_exit_json['msg'] == 'Role mapper role-ldap-mapper1 created.' + assert ansible_exit_json['changed'] + reference_state = clean_payload_with_config(reference_payload) + reference_state['id'] = '123-123' + assert ansible_exit_json['role_mapper'] == reference_state + create_call_arguments = json.loads(mock_create_url.mock_calls[3][2]['data']) + assert create_call_arguments == reference_payload + + +def test_missing_mandatory_arguments_should_raise_an_error(mock_create_url, monkeypatch): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'role-ldap-mapper1', + 'federation_id': 'my-company-ldap', + } + set_module_args(arguments) + with pytest.raises(AnsibleFailJson) as ansible_stacktrace: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + assert ansible_exit_json['msg'] == 'roles_dn is mandatory for role mapper creation.' + assert not ansible_exit_json['changed'] + assert ansible_exit_json['role_mapper'] == {} + + +@pytest.fixture() +def mock_does_not_exist_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=does-not-exist': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_absent_state_and_mapper_does_not_exist_should_do_nothing( + monkeypatch, mock_does_not_exist_url +): + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_role_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist', + 'federation_id': 'my-company-ldap', + 'state': 'absent', + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_ldap_role_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + assert ansible_exit_json['msg'] == 'Role mapper does-not-exist does not exist, doing nothing.' + assert not ansible_exit_json['changed'] + assert ansible_exit_json['role_mapper'] == {} From 85acfdc98af00e0507f6d15b1fa37fc21763cf0f Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 23 Sep 2019 08:49:40 +0200 Subject: [PATCH 75/79] refactor: factorize FederationGroupMapper and FederationRoleMapper classes --- .../identity/keycloak/keycloak_ldap_mapper.py | 146 ++++++++++++++++++ .../keycloak/keycloak_ldap_group_mapper.py | 130 +--------------- .../keycloak/keycloak_ldap_role_mapper.py | 141 ++--------------- 3 files changed, 165 insertions(+), 252 deletions(-) create mode 100644 lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py new file mode 100644 index 00000000000000..59f74e22d6d73d --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py @@ -0,0 +1,146 @@ +# Copyright: (c) 2018, Nicolas Duclert +# 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 copy import deepcopy + +from ansible.module_utils.common.dict_transformations import recursive_diff +from ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakError, + get_on_url, + put_on_url, + delete_on_url, + post_on_url, +) +from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( + LdapFederationBase, + COMPONENTS_URL, +) +from ansible.module_utils.identity.keycloak.urls import COMPONENTS_BY_UUID_URL, MAPPER_BY_NAME +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config + +from ansible.module_utils.six.moves.urllib.parse import quote + +WAITED_PROVIDER_ID = {'role': 'role-ldap-mapper', 'group': 'group-ldap-mapper'} + + +class FederationMapper(object): + def __init__(self, module, connection_header, mapper_type): + self.module = module + self.restheaders = connection_header + self.uuid = self.module.params.get('mapper_uuid') + self.description = '{mapper_type} mapper {given_id}'.format( + given_id=self.given_id, mapper_type=mapper_type + ) + if self.module.params.get('mapper_name'): + self.federation = LdapFederationBase(module, connection_header) + if not self.federation.uuid: + raise KeycloakError( + 'Cannot access mapper because {} federation does not exist.'.format( + self.federation.given_id + ) + ) + else: + self.federation = None + self.initial_representation = self.representation + try: + self.uuid = self.initial_representation['id'] + except KeyError: + pass + else: + if self.initial_representation['providerId'] != WAITED_PROVIDER_ID[mapper_type]: + raise KeycloakError( + '{given_id} is not a {mapper_type} mapper.'.format( + given_id=self.given_id, mapper_type=mapper_type + ) + ) + + @property + def given_id(self): + """Get the asked mapper id given by the user. + + :return the asked id given by the user as a name or an uuid. + :rtype: str + """ + if self.module.params.get('mapper_name'): + return self.module.params.get('mapper_name') + return self.module.params.get('mapper_uuid') + + @property + def representation(self): + return clean_payload_with_config( + get_on_url( + url=self._get_mapper_url(), + restheaders=self.restheaders, + module=self.module, + description=self.description, + ) + ) + + def _get_mapper_url(self): + """Create the url in order to get the federation from the given argument (uuid or name) + :return: the url as string + :rtype: str""" + if self.uuid: + return COMPONENTS_BY_UUID_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + uuid=quote(self.uuid), + ) + return MAPPER_BY_NAME.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + mapper_name=quote(self.module.params.get('mapper_name').lower()), + federation_uuid=self.federation.uuid, + ) + + def update(self, check=False): + if not self._arguments_update_representation(): + return {} + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + put_on_url( + self._get_mapper_url(), self.restheaders, self.module, self.description, payload + ) + return clean_payload_with_config(payload) + + def _arguments_update_representation(self): + clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) + payload_without_empty_values = deepcopy(clean_payload) + for key, value in clean_payload['config'].items(): + if not value: + payload_without_empty_values['config'].pop(key) + payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) + try: + config_diff = payload_diff.pop('config') + except KeyError: + config_diff = {} + if not payload_diff and not config_diff: + return False + return True + + def _create_payload(self): + raise NotImplemented + + def delete(self): + delete_on_url(self._get_mapper_url(), self.restheaders, self.module, self.description) + + def create(self, check=False): + payload = self._create_payload() + if check: + return clean_payload_with_config(payload) + post_url = COMPONENTS_URL.format( + url=self.module.params.get('auth_keycloak_url'), + realm=quote(self.module.params.get('realm')), + ) + post_on_url( + url=post_url, + restheaders=self.restheaders, + module=self.module, + description=self.description, + representation=payload, + ) + return clean_payload_with_config(payload) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py index 27b10001590fa6..e825d2eb8628d1 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -318,91 +318,29 @@ sample: false ''' -from copy import deepcopy - -from ansible.module_utils.common.dict_transformations import recursive_diff, dict_merge +from ansible.module_utils.common.dict_transformations import dict_merge from ansible.module_utils.identity.keycloak.crud import crud_with_instance from ansible.module_utils.basic import AnsibleModule -from ansible.module_utils.six.moves.urllib.parse import quote from ansible.module_utils.identity.keycloak.utils import ( - clean_payload_with_config, if_absent_add_a_default_value, ) from ansible.module_utils.identity.keycloak.keycloak import ( keycloak_argument_spec, get_token, KeycloakError, - get_on_url, - delete_on_url, - put_on_url, - post_on_url, -) -from ansible.module_utils.identity.keycloak.urls import ( - COMPONENTS_BY_UUID_URL, - MAPPER_BY_NAME, ) +from ansible.module_utils.identity.keycloak.keycloak_ldap_mapper import FederationMapper + from ansible.module_utils.identity.keycloak.utils import snake_to_point_case, convert_to_bool -from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( - LdapFederationBase, - COMPONENTS_URL, -) USER_GROUP_RETRIEVE_STRATEGY_LABEL = 'user.roles.retrieve.strategy' -class FederationGroupMapper(object): +class FederationGroupMapper(FederationMapper): def __init__(self, module, connection_header): - self.module = module - self.restheaders = connection_header - self.uuid = self.module.params.get('mapper_uuid') - self.description = 'group mapper {}'.format(self.given_id) - if self.module.params.get('mapper_name'): - self.federation = LdapFederationBase(module, connection_header) - if not self.federation.uuid: - raise KeycloakError( - 'Cannot access mapper because {} federation does not exist.'.format( - self.federation.given_id - ) - ) - else: - self.federation = None - self.initial_representation = self.representation - try: - self.uuid = self.initial_representation['id'] - except KeyError: - pass - else: - if self.initial_representation['providerId'] != 'group-ldap-mapper': - raise KeycloakError( - '{given_id} is not a group mapper.'.format(given_id=self.given_id)) - - @property - def given_id(self): - """Get the asked mapper id given by the user. - - :return the asked id given by the user as a name or an uuid. - :rtype: str - """ - if self.module.params.get('mapper_name'): - return self.module.params.get('mapper_name') - return self.module.params.get('mapper_uuid') - - def _get_mapper_url(self): - """Create the url in order to get the federation from the given argument (uuid or name) - :return: the url as string - :rtype: str""" - if self.uuid: - return COMPONENTS_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=quote(self.uuid), - ) - return MAPPER_BY_NAME.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - mapper_name=quote(self.module.params.get('mapper_name').lower()), - federation_uuid=self.federation.uuid, + super(FederationGroupMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='group' ) def _create_payload(self): @@ -507,64 +445,10 @@ def _check_arguments(new_configuration): 'LDAP filter should begin with a opening bracket and end with closing bracket.' ) - def delete(self): - delete_on_url(self._get_mapper_url(), self.restheaders, self.module, self.description) - - def _arguments_update_representation(self): - clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) - payload_without_empty_values = deepcopy(clean_payload) - for key, value in clean_payload['config'].items(): - if not value: - payload_without_empty_values['config'].pop(key) - payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) - try: - config_diff = payload_diff.pop('config') - except KeyError: - config_diff = {} - if not payload_diff and not config_diff: - return False - return True - - def update(self, check=False): - if not self._arguments_update_representation(): - return {} - payload = self._create_payload() - if check: - return clean_payload_with_config(payload) - put_on_url( - self._get_mapper_url(), self.restheaders, self.module, self.description, payload - ) - return clean_payload_with_config(payload) - def create(self, check=False): if not self.module.params.get('groups_dn'): raise KeycloakError('groups_dn is mandatory for group mapper creation.') - payload = self._create_payload() - if check: - return clean_payload_with_config(payload) - post_url = COMPONENTS_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - ) - post_on_url( - url=post_url, - restheaders=self.restheaders, - module=self.module, - description=self.description, - representation=payload, - ) - return clean_payload_with_config(payload) - - @property - def representation(self): - return clean_payload_with_config( - get_on_url( - url=self._get_mapper_url(), - restheaders=self.restheaders, - module=self.module, - description=self.description, - ) - ) + super(FederationGroupMapper, self).create(check) def run_module(): diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py index 4275045eb95882..b9f0c7f5d75b5f 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py @@ -5,13 +5,6 @@ # 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 -from ansible.module_utils.identity.keycloak.urls import COMPONENTS_BY_UUID_URL, MAPPER_BY_NAME -from ansible.module_utils.identity.keycloak.utils import ( - clean_payload_with_config, - snake_to_point_case, - if_absent_add_a_default_value, -) - __metaclass__ = type ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} @@ -325,121 +318,29 @@ sample: admin-cli ''' -from copy import deepcopy - from ansible.module_utils.identity.keycloak.keycloak import ( KeycloakError, - get_on_url, keycloak_argument_spec, get_token, - put_on_url, - delete_on_url, - post_on_url, ) -from ansible.module_utils.identity.keycloak.keycloak_ldap_federation import ( - LdapFederationBase, - COMPONENTS_URL, -) -from ansible.module_utils.six.moves.urllib.parse import quote -from ansible.module_utils.common.dict_transformations import recursive_diff, dict_merge +from ansible.module_utils.common.dict_transformations import dict_merge from ansible.module_utils.identity.keycloak.crud import crud_with_instance from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.identity.keycloak.keycloak import KeycloakAPI +from ansible.module_utils.identity.keycloak.keycloak_ldap_mapper import FederationMapper +from ansible.module_utils.identity.keycloak.utils import ( + snake_to_point_case, + if_absent_add_a_default_value, +) -class FederationRoleMapper(object): +class FederationRoleMapper(FederationMapper): def __init__(self, module, connection_header): - self.module = module - self.restheaders = connection_header - self.uuid = self.module.params.get('mapper_uuid') - self.description = 'role mapper {}'.format(self.given_id) - if self.module.params.get('mapper_name'): - self.federation = LdapFederationBase(module, connection_header) - if not self.federation.uuid: - raise KeycloakError( - 'Cannot access mapper because {given_id} federation does not exist.'.format( - given_id=self.federation.given_id - ) - ) - else: - self.federation = None - self.initial_representation = self.representation - try: - self.uuid = self.initial_representation['id'] - except KeyError: - pass - else: - if self.initial_representation['providerId'] != 'role-ldap-mapper': - raise KeycloakError( - '{given_id} is not a role mapper.'.format(given_id=self.given_id) - ) - - @property - def given_id(self): - """Get the asked mapper id given by the user. - - :return the asked id given by the user as a name or an uuid. - :rtype: str - """ - if self.module.params.get('mapper_name'): - return self.module.params.get('mapper_name') - return self.module.params.get('mapper_uuid') - - @property - def representation(self): - return clean_payload_with_config( - get_on_url( - url=self._get_mapper_url(), - restheaders=self.restheaders, - module=self.module, - description=self.description, - ) - ) - - def _get_mapper_url(self): - """Create the url in order to get the federation from the given argument (uuid or name) - :return: the url as string - :rtype: str""" - if self.uuid: - return COMPONENTS_BY_UUID_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - uuid=quote(self.uuid), - ) - return MAPPER_BY_NAME.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - mapper_name=quote(self.module.params.get('mapper_name').lower()), - federation_uuid=self.federation.uuid, + super(FederationRoleMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='role' ) - def _arguments_update_representation(self): - clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) - payload_without_empty_values = deepcopy(clean_payload) - for key, value in clean_payload['config'].items(): - if not value: - payload_without_empty_values['config'].pop(key) - payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) - try: - config_diff = payload_diff.pop('config') - except KeyError: - config_diff = {} - if not payload_diff and not config_diff: - return False - return True - - def update(self, check=False): - if not self._arguments_update_representation(): - return {} - payload = self._create_payload() - if check: - return clean_payload_with_config(payload) - put_on_url( - self._get_mapper_url(), self.restheaders, self.module, self.description, payload - ) - return clean_payload_with_config(payload) - def _create_payload(self): translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} config = {} @@ -513,27 +414,10 @@ def _check_arguments(self, new_configuration): ) ) - def delete(self): - delete_on_url(self._get_mapper_url(), self.restheaders, self.module, self.description) - def create(self, check=False): if not self.module.params.get('roles_dn'): raise KeycloakError('roles_dn is mandatory for role mapper creation.') - payload = self._create_payload() - if check: - return clean_payload_with_config(payload) - post_url = COMPONENTS_URL.format( - url=self.module.params.get('auth_keycloak_url'), - realm=quote(self.module.params.get('realm')), - ) - post_on_url( - url=post_url, - restheaders=self.restheaders, - module=self.module, - description=self.description, - representation=payload, - ) - return clean_payload_with_config(payload) + super(FederationRoleMapper, self).create(check) def run_module(): @@ -562,10 +446,9 @@ def run_module(): ), memberof_ldap_attribute=dict( type=str - ), # used only if user roles retrieve strategy is GET_ROLES_FROM_USER_MEMBEROF_ATTRIBUTE + ), use_realm_roles_mapping=dict(type=bool), - # the value should be in the list of client id of the realm (to check) - client_id=dict(type=str), # used only if user realm roles mapping is False. + client_id=dict(type=str), ) argument_spec = keycloak_argument_spec() argument_spec.update(meta_args) From 716aa660d5c82370e1a201792db45794078d80cb Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Mon, 23 Sep 2019 10:22:48 +0200 Subject: [PATCH 76/79] feature: create CRUD user attribute mapper module --- .../identity/keycloak/keycloak_ldap_mapper.py | 4 +- .../keycloak_ldap_user_attributes_mapper.py | 345 ++++++++++++ ...st_keycloak_ldap_user_attributes_mapper.py | 533 ++++++++++++++++++ 3 files changed, 880 insertions(+), 2 deletions(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_ldap_user_attributes_mapper.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_ldap_user_attributes_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py index 59f74e22d6d73d..0c2a14a882153e 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py @@ -23,7 +23,7 @@ from ansible.module_utils.six.moves.urllib.parse import quote -WAITED_PROVIDER_ID = {'role': 'role-ldap-mapper', 'group': 'group-ldap-mapper'} +WAITED_PROVIDER_ID = {'role': 'role-ldap-mapper', 'group': 'group-ldap-mapper', 'user attributes': 'user-attribute-ldap-mapper'} class FederationMapper(object): @@ -111,7 +111,7 @@ def _arguments_update_representation(self): clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) payload_without_empty_values = deepcopy(clean_payload) for key, value in clean_payload['config'].items(): - if not value: + if not isinstance(value, bool) and not value: payload_without_empty_values['config'].pop(key) payload_diff, _ = recursive_diff(payload_without_empty_values, self.initial_representation) try: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_user_attributes_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_user_attributes_mapper.py new file mode 100644 index 00000000000000..03bf3a9464c8ee --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_user_attributes_mapper.py @@ -0,0 +1,345 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.identity.keycloak.crud import crud_with_instance +from ansible.module_utils.identity.keycloak.utils import ( + snake_to_point_case, + if_absent_add_a_default_value, +) + +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: keycloak_ldap_user_attributes_mapper + +short_description: Allows administration of Keycloak LDAP user attribute mapper via Keycloak API +description: + - This module allows you to add, remove or modify Keycloak LDAP user attribute mapper via the Keycloak 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. + + - When updating a LDAP user attribute mapper federation, where possible provide the mapper ID to the + module. This removes a lookup to the API to translate the name into the mapper ID. + + - This module has been tested against keycloak 6.0, the backward compatibility is not garanteed. + +version_added: "2.10" +options: + state: + description: + - State of the LDAP federation mapper. + - On C(present), the user attribute mapper will be created if it does not yet exist, or + updated with the parameters you provide. + - On C(absent), the user attribute mapper will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP user attribute mapper resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federation in the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with federation_uuid and one + of them is required by the module + type: str + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with federation_id and one + of them is required by the module + type: str + + mapper_name: + description: + - The name of the user attribute mapper + - This parameter is mutually exclusive with mapper_uuid and one + of them is required by the module + type: str + + mapper_uuid: + description: + - The uuid of the user attribute mapper + - This parameter is mutually exclusive with mapper_name and one + of them is required by the module + type: str + + user_model_attribute: + description: + - Name of the UserModel property or attribute you want to map the LDAP attribute into + - Mandatory when creating the mapper + type: str + + ldap_attribute: + description: + - Name of mapped attribute on LDAP object + - Mandatory when creating the mapper + type: str + + is_mandatory_in_ldap: + description: + - if C(True), attribute is mandatory in LDAP. Hence if there is no value in Keycloak DB, the + empty value will be set to be propagated to LDAP + type: bool + + read_only: + description: + - if C(True), attribute is imported from LDAP to UserModel, but it's not saved back to LDAP + when user is updated in Keycloak. + type: bool + + always_read_value_from_ldap: + description: + - If C(True), then during reading of the LDAP attribute value will always used instead of + the value from Keycloak DB + type: bool + + is_binary_attribute: + description: + - Should be C(True) for binary LDAP attributes + - If set to C(True), then I(always_read_value_from_ldap) must be set to C(True). + type: bool +''' + +EXAMPLES = r''' +- name: Role exists, update it + keycloak_ldap_user_attributes_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: present + federation_id: my-company-ldap + mapper_name: user-attribute-mapper-for-company + is_mandatory_in_ldap: yes + read_only: yes + always_read_value_from_ldap: yes + is_binary_attribute: no +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "User attributes mapper my-group-mapper created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +user_attribute_mapper: + description: the LDAP user attributes mapper representation. Empty if the asked user attributes mapper is deleted or does not exist. + returned: always + type: dict + contains: + name: + description: name of the LDAP role mapper + type: str + returned: on success + sample: group-ldap-mapper1 + providerId: + description: the id of the role mapper, always C(role-ldap-mapper) for this module + type: str + returned: on success + sample: role-ldap-mapper + parentId: + description: the LDAP role mapper parent uuid + type: str + returned: on success + sample: de455375-6900-46a0-8d11-51554e1c3f18 + providerType: + description: the type of the object, for this module always C(org.keycloak.storage.ldap.mappers.LDAPStorageMapper) + type: str + returned: on success + sample: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + config: + description: the configuration of the LDAP role mapper + type: dict + returned: on success + contains: + user.model.attribute: + description: Name of the UserModel property or attribute you want to map the LDAP attribute into + returned: on success + sample: firstName + ldap.attribute: + description: Name of mapped attribute on LDAP object + returned: on success + sample: cn + read.only: + description: Read-only attribute is imported from LDAP to UserModel + returned: on success + sample: true + always.read.value.from.ldap: + description: If on, then during reading of the LDAP attribute value will always used instead of the value from Keycloak DB + returned: on success + sample: true + is.mandatory.in.ldap: + description: If true, attribute is mandatory in LDAP. Hence if there is no value in Keycloak DB, the empty value will be set to be propagated to LDAP + returned: on success + sample: true + is.binary.attribute: + description: Should be true for binary LDAP attributes + returned: on success + sample: true +''' + +from ansible.module_utils.identity.keycloak.keycloak_ldap_mapper import FederationMapper + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + get_token, + KeycloakError, +) + + +class FederationUserAttributesMapper(FederationMapper): + def __init__(self, module, connection_header): + super(FederationUserAttributesMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='user attributes' + ) + + def _create_payload(self): + translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} + config = {} + payload = { + 'providerId': 'user-attribute-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + not_mapper_argument = list(keycloak_argument_spec().keys()) + [ + 'state', + 'realm', + 'federation_id', + ] + if self.federation: + payload.update({'parentId': self.federation.uuid}) + for key, value in self.module.params.items(): + if value is not None and key not in not_mapper_argument: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + elif isinstance(value, bool): + config.update({snake_to_point_case(key): [str(value).lower()]}) + else: + config.update({snake_to_point_case(key): [value]}) + try: + old_configuration = { + key: [value] for key, value in self.initial_representation['config'].items() + } + except KeyError: + old_configuration = {} + new_configuration = dict_merge(old_configuration, config) + self._check_arguments(new_configuration) + dict_of_default = { + 'is.mandatory.in.ldap': 'false', + 'is.binary.attribute': 'false', + 'always.read.value.from.ldap': 'false', + 'read.only': 'false', + } + payload.update( + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} + ) + return payload + + @staticmethod + def _check_arguments(new_configuration): + try: + binary_attribute = new_configuration[snake_to_point_case('is_binary_attribute')][0] + always_read = new_configuration[snake_to_point_case('always_read_value_from_ldap')][0] + except KeyError: + pass + else: + if binary_attribute == 'true' and always_read == 'false': + raise KeycloakError( + 'With is_binary_attribute to yes, the always_read_value_from_ldap must be to yes too' + ) + + def create(self, check=False): + ldap_attribute = self.module.params.get('ldap_attribute') + user_model_attribute = self.module.params.get('user_model_attribute') + if not ldap_attribute or not user_model_attribute: + raise KeycloakError( + 'ldap_attribute and user_model_attribute are mandatory for creating a user ' + 'attributes mapper.' + ) + super(FederationUserAttributesMapper, self).create(check) + + +def run_module(): + meta_args = dict( + realm=dict(type='str', default='master'), + federation_id=dict(type='str'), + federation_uuid=dict(type='str'), + mapper_name=dict(type='str'), + mapper_uuid=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']), + user_model_attribute=dict(type='str'), + ldap_attribute=dict(type='str'), + is_mandatory_in_ldap=dict(type='bool'), + read_only=dict(type='bool'), + always_read_value_from_ldap=dict(type='bool'), + is_binary_attribute=dict(type='bool'), + ) + argument_spec = keycloak_argument_spec() + argument_spec.update(meta_args) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['mapper_name', 'mapper_uuid']], + mutually_exclusive=[['mapper_name', 'mapper_uuid']], + ) + if module.params.get('mapper_name') and not ( + module.params.get('federation_id') or module.params.get('federation_uuid') + ): + module.fail_json( + msg='With mapper name, the federation_id or federation_uuid must be given.', + changed=False, + user_attribute_mapper={}, + ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + role_mapper = FederationUserAttributesMapper(module, connection_header) + result = crud_with_instance(role_mapper, 'user_attribute_mapper') + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, user_attribute_mapper={}) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_ldap_user_attributes_mapper.py b/test/units/modules/identity/keycloak/test_keycloak_ldap_user_attributes_mapper.py new file mode 100644 index 00000000000000..19f61d4480ff19 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_user_attributes_mapper.py @@ -0,0 +1,533 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +import pytest +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config +from ansible.modules.identity.keycloak import keycloak_ldap_user_attributes_mapper + +__metaclass__ = type + +import json +from copy import deepcopy +from itertools import count + +from ansible.module_utils.six import StringIO +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) + +MAPPER_DICT = { + 'id': '123-123', + 'name': 'user-attributes-mapper1', + 'providerId': 'user-attribute-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'ldap.attribute': ['attribute'], + 'is.mandatory.in.ldap': ['false'], + 'is.binary.attribute': ['false'], + 'always.read.value.from.ldap': ['false'], + 'read.only': ['true'], + 'user.model.attribute': ['firstName'], + }, +} + +WRONG_TYPE_MAPPER = deepcopy(MAPPER_DICT) +WRONG_TYPE_MAPPER.update({'providerId': 'role-ldap-mapper'}) + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def test_mapper_name_without_federation_id_should_fail(monkeypatch): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'With mapper name, the federation_id or federation_uuid must be given.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] + + +@pytest.fixture +def mock_absent_federation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=does-not-exist': create_wrapper( + json.dumps([]) + ) + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_federation_does_not_exist_fail(monkeypatch, mock_absent_federation_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'does-not-exist', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'Cannot access mapper because does-not-exist federation does not exist.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] + + +@pytest.fixture() +def mock_wrong_type(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=user-attributes-mapper1': create_wrapper( + json.dumps(WRONG_TYPE_MAPPER) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_good_name_but_wrong_type_should_raise_an_error(monkeypatch, mock_wrong_type): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'my-company-ldap', + 'mapper_name': 'user-attributes-mapper1', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'user-attributes-mapper1 is not a user attributes mapper.' + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] + + +@pytest.fixture() +def mock_existing_mapper(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=user-attributes-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps(MAPPER_DICT) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'mapper_name': 'user-attributes-mapper1', 'federation_id': 'my-company-ldap'}, + {'mapper_uuid': '123-123'}, + ], + ids=['mapper and federation names', 'mapper uuid'], +) +def test_present_user_attributes_mapper_without_properties_should_do_nothing( + monkeypatch, extra_arguments, mock_existing_mapper +): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + try: + given_id = extra_arguments['mapper_name'] + except KeyError: + given_id = extra_arguments['mapper_uuid'] + assert ansible_exit_json[ + 'msg' + ] == 'User attributes mapper {} up to date, doing nothing.'.format(given_id) + assert not ansible_exit_json['changed'] + assert ansible_exit_json['user_attribute_mapper'] == { + 'id': '123-123', + 'name': 'user-attributes-mapper1', + 'providerId': 'user-attribute-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'ldap.attribute': 'attribute', + 'is.mandatory.in.ldap': 'false', + 'is.binary.attribute': 'false', + 'always.read.value.from.ldap': 'false', + 'read.only': 'true', + 'user.model.attribute': 'firstName', + }, + } + + +@pytest.fixture() +def mock_delete_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=user-attributes-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_absent_should_delete_existing_mapper(monkeypatch, mock_delete_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'user-attributes-mapper1', + 'federation_id': 'my-company-ldap', + 'state': 'absent', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'User attributes mapper user-attributes-mapper1 deleted.' + assert ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] + delete_call = mock_delete_url.mock_calls[3] + assert delete_call[2]['method'] == 'DELETE' + + +@pytest.fixture() +def mock_update_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + updated_mapper = deepcopy(MAPPER_DICT) + updated_mapper['config']['ldap.attribute'] = ['first_name'] + updated_mapper['config']['read.only'] = ['false'] + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=user-attributes-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(updated_mapper)), + ], + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'user-attributes-mapper1', + 'federation_id': 'my-company-ldap', + 'ldap_attribute': 'first_name', + 'is_mandatory_in_ldap': False, + 'read_only': False, + 'always_read_value_from_ldap': False, + 'is_binary_attribute': False, + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'User attributes mapper user-attributes-mapper1 updated.' + assert ansible_exit_json['changed'] + update_call_arguments = json.loads(mock_update_url.mock_calls[3][2]['data']) + reference_arguments = { + 'name': 'user-attributes-mapper1', + 'providerId': 'user-attribute-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': { + 'ldap.attribute': ['first_name'], + 'is.mandatory.in.ldap': ['false'], + 'is.binary.attribute': ['false'], + 'always.read.value.from.ldap': ['false'], + 'read.only': ['false'], + 'user.model.attribute': ['firstName'], + }, + } + assert update_call_arguments == reference_arguments + reference_arguments['id'] = '123-123' + assert ansible_exit_json['user_attribute_mapper'] == clean_payload_with_config( + reference_arguments + ) + + +def test_incompatible_arguments_should_fail(monkeypatch, mock_update_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'user-attributes-mapper1', + 'federation_id': 'my-company-ldap', + 'always_read_value_from_ldap': False, + 'is_binary_attribute': True, + } + set_module_args(arguments) + waited_error = ( + 'With is_binary_attribute to yes, the always_read_value_from_ldap must be to yes too' + ) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] + assert ansible_exit_json['msg'] == waited_error + + +@pytest.fixture() +def mock_create_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=user-attributes-mapper1': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(MAPPER_DICT)), + ], + 'http://keycloak.url/auth/admin/realms/master/components': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'user-attributes-mapper1', + 'federation_id': 'my-company-ldap', + 'ldap_attribute': 'attribute', + 'user_model_attribute': 'firstName', + 'read_only': True, + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + reference_final_state = deepcopy(MAPPER_DICT) + + assert ansible_exit_json['msg'] == 'User attributes mapper user-attributes-mapper1 created.' + assert ansible_exit_json['changed'] + assert ansible_exit_json['user_attribute_mapper'] == clean_payload_with_config( + reference_final_state + ) + create_call_arguments = json.loads(mock_create_url.mock_calls[3][2]['data']) + reference_payload = deepcopy(reference_final_state) + reference_payload.pop('id') + assert create_call_arguments == reference_payload + + +def test_missing_argument_for_creation_should_raise_an_error(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_ldap_user_attributes_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'user-attributes-mapper1', + 'federation_id': 'my-company-ldap', + 'ldap_attribute': 'attribute', + 'read_only': True, + } + + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as ansible_stacktrace: + keycloak_ldap_user_attributes_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + assert ansible_exit_json['msg'] == ( + 'ldap_attribute and user_model_attribute are mandatory for creating a user attributes ' + 'mapper.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['user_attribute_mapper'] From 881ddb1ecfba69d15cea1dfcd522861fa8c19358 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Tue, 24 Sep 2019 08:48:48 +0200 Subject: [PATCH 77/79] feature: create CRUD full name ldap mapper --- .../identity/keycloak/keycloak_ldap_mapper.py | 7 +- .../keycloak_full_name_ldap_mapper.py | 309 ++++++++++++ .../test_keycloak_full_name_ldap_mapper.py | 468 ++++++++++++++++++ 3 files changed, 783 insertions(+), 1 deletion(-) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_full_name_ldap_mapper.py create mode 100644 test/units/modules/identity/keycloak/test_keycloak_full_name_ldap_mapper.py diff --git a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py index 0c2a14a882153e..395f70ac0acc64 100644 --- a/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py @@ -23,7 +23,12 @@ from ansible.module_utils.six.moves.urllib.parse import quote -WAITED_PROVIDER_ID = {'role': 'role-ldap-mapper', 'group': 'group-ldap-mapper', 'user attributes': 'user-attribute-ldap-mapper'} +WAITED_PROVIDER_ID = { + 'role': 'role-ldap-mapper', + 'group': 'group-ldap-mapper', + 'user attributes': 'user-attribute-ldap-mapper', + 'full name': 'full-name-ldap-mapper', +} class FederationMapper(object): diff --git a/lib/ansible/modules/identity/keycloak/keycloak_full_name_ldap_mapper.py b/lib/ansible/modules/identity/keycloak/keycloak_full_name_ldap_mapper.py new file mode 100644 index 00000000000000..0bffb7f44d88ca --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_full_name_ldap_mapper.py @@ -0,0 +1,309 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Nicolas Duclert +# 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 + +ANSIBLE_METADATA = {'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'} + +DOCUMENTATION = r''' +--- +module: keycloak_ldap_full_name_mapper + +short_description: Allows administration of Keycloak LDAP full name mapper via Keycloak API +description: + - This module allows you to add, remove or modify Keycloak LDAP full name mapper via the Keycloak 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. + + - When updating a LDAP full name mapper federation, where possible provide the mapper ID to the + module. This removes a lookup to the API to translate the name into the mapper ID. + + - This module has been tested against keycloak 6.0, the backward compatibility is not guaranteed. + +version_added: "2.10" +options: + state: + description: + - State of the LDAP federation mapper. + - On C(present), the full name mapper will be created if it does not yet exist, or updated + with the parameters you provide. + - On C(absent), the full name mapper will be removed if it exists. + required: true + default: present + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP full name mapper resides. + default: 'master' + + federation_id: + description: + - The name of the federation + - Also called ID of the federation in the table of federations or + the console display name in the detailed view of a federation + - This parameter is mutually exclusive with I(federation_uuid) and one + of them is required by the module if I(mapper_name) is given + type: str + + federation_uuid: + description: + - The uuid of the federation + - This parameter is mutually exclusive with I(federation_id) and one + of them is required by the module if I(mapper_name) is given + type: str + + mapper_name: + description: + - The name of the full name mapper + - This parameter is mutually exclusive with I(mapper_uuid) and one + of them is required by the module + type: str + + mapper_uuid: + description: + - The uuid of the full name mapper + - This parameter is mutually exclusive with I(mapper_name) and one + of them is required by the module + type: str + + ldap_full_name_attribute: + description: + - Name of LDAP attribute, which contains fullName of user + type: str + + read_only: + description: + - if C(True) data imported from LDAP to Keycloak DB, but it's not saved back to LDAP when + user is updated in Keycloak + type: bool + + write_only: + description: + - if C(True) data propagated to LDAP when user is created or updated in Keycloak. But this + mapper is not used to propagate data from LDAP back into Keycloak. This setting is useful + if you configured separate firstName and lastName attribute mappers and you want to use + those to read attribute from LDAP into Keycloak + type: bool +''' + +EXAMPLES = r''' +- name: Minimal creation + keycloak_full_name_ldap_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + federation_id: my-company-ldap + mapper_name: full-name-mapper-for-company + ldap_full_name_attribute: dn +- name: Creation or update with all arguments + keycloak_full_name_ldap_mapper: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + federation_id: my-company-ldap + mapper_name: full-name-mapper-for-company + ldap_full_name_attribute: cn + read_only: yes + write_only: no +''' + +RETURN = r''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "role mapper my-group-mapper created." + +changed: + description: whether the state of the keycloak configuration change + returned: always + type: bool + +full_name_ldap_mapper: + description: the LDAP full name mapper representation. Empty if the asked full name mapper is deleted or does not exist. + returned: always + type: dict + contains: + name: + description: name of the LDAP role mapper + type: str + returned: on success + sample: group-ldap-mapper1 + providerId: + description: the id of the full name mapper, always C(full-name-ldap-mapper) for this module + type: str + returned: on success + sample: role-ldap-mapper + parentId: + description: the LDAP full name mapper parent uuid + type: str + returned: on success + sample: de455375-6900-46a0-8d11-51554e1c3f18 + providerType: + description: the type of the object, for this module always C(org.keycloak.storage.ldap.mappers.LDAPStorageMapper) + type: str + returned: on success + sample: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + config: + description: the configuration of the LDAP full name mapper + type: dict + returned: on success + contains: + ldap.full.name.attribute: + description: Name of LDAP attribute, which contains fullName of user + type: str + returned: on success + read.only: + description: show if data is imported from LDAP to Keycloak DB + type: bool + returned: on success + write.only: + description: show if data propagated to LDAP when user is created or updated in Keycloak + type: bool + returned: on success +''' + +from ansible.module_utils.common.dict_transformations import dict_merge +from ansible.module_utils.identity.keycloak.crud import crud_with_instance +from ansible.module_utils.identity.keycloak.utils import ( + snake_to_point_case, + if_absent_add_a_default_value, +) +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + get_token, + KeycloakError, +) +from ansible.module_utils.identity.keycloak.keycloak_ldap_mapper import FederationMapper + + +class FullNameLdapMapper(FederationMapper): + def __init__(self, module, connection_header): + super(FullNameLdapMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='full name' + ) + + def _create_payload(self): + translation = {'mapper_name': 'name', 'mapper_uuid': 'id'} + config = {} + payload = { + 'providerId': 'full-name-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + not_mapper_argument = list(keycloak_argument_spec().keys()) + [ + 'state', + 'realm', + 'federation_id', + ] + if self.federation: + payload.update({'parentId': self.federation.uuid}) + for key, value in self.module.params.items(): + if value is not None and key not in not_mapper_argument: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + elif isinstance(value, bool): + config.update({snake_to_point_case(key): [str(value).lower()]}) + else: + config.update({snake_to_point_case(key): [value]}) + try: + old_configuration = { + key: [value] for key, value in self.initial_representation['config'].items() + } + except KeyError: + old_configuration = {} + new_configuration = dict_merge(old_configuration, config) + dict_of_default = {'read.only': 'false', 'write.only': 'true'} + payload.update( + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} + ) + self._check_arguments(new_configuration) + return payload + + @staticmethod + def _check_arguments(new_configuration): + try: + read_only = new_configuration[snake_to_point_case('read_only')][0] + write_only = new_configuration[snake_to_point_case('write_only')][0] + except KeyError: + pass + else: + if read_only.lower() == 'true' and write_only.lower() == 'true': + raise KeycloakError('Cannot have read only and write only together') + + def create(self, check=False): + if not self.module.params.get('ldap_full_name_attribute'): + raise KeycloakError('ldap_full_name_attribute is mandatory for full name mapper creation.') + super(FullNameLdapMapper, self).create(check) + + +def run_module(): + meta_args = dict( + realm=dict(type='str', default='master'), + federation_id=dict(type='str'), + federation_uuid=dict(type='str'), + mapper_name=dict(type='str'), + mapper_uuid=dict(type='str'), + state=dict(type='str', default='present', choices=['present', 'absent']), + ldap_full_name_attribute=dict(type='str'), + read_only=dict(type='bool'), + write_only=dict(type='bool'), + ) + argument_spec = keycloak_argument_spec() + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['mapper_name', 'mapper_uuid']], + mutually_exclusive=[['mapper_name', 'mapper_uuid']], + ) + if module.params.get('mapper_name') and not ( + module.params.get('federation_id') or module.params.get('federation_uuid') + ): + module.fail_json( + msg='With mapper name, the federation_id or federation_uuid must be given.', + changed=False, + full_name_ldap_mapper={}, + ) + try: + connection_header = get_token( + base_url=module.params.get('auth_keycloak_url'), + validate_certs=module.params.get('validate_certs'), + auth_realm=module.params.get('auth_realm'), + client_id=module.params.get('auth_client_id'), + auth_username=module.params.get('auth_username'), + auth_password=module.params.get('auth_password'), + client_secret=module.params.get('auth_client_secret'), + ) + role_mapper = FullNameLdapMapper(module, connection_header) + result = crud_with_instance(role_mapper, 'full_name_ldap_mapper') + except KeycloakError as err: + module.fail_json(msg=str(err), changed=False, full_name_ldap_mapper={}) + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/identity/keycloak/test_keycloak_full_name_ldap_mapper.py b/test/units/modules/identity/keycloak/test_keycloak_full_name_ldap_mapper.py new file mode 100644 index 00000000000000..92186468059b6c --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_full_name_ldap_mapper.py @@ -0,0 +1,468 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +from copy import deepcopy +from itertools import count + +import pytest +from ansible.module_utils.identity.keycloak.utils import clean_payload_with_config +from ansible.modules.identity.keycloak import keycloak_full_name_ldap_mapper +from ansible.module_utils.six import StringIO +from units.modules.utils import ( + AnsibleExitJson, + AnsibleFailJson, + fail_json, + exit_json, + set_module_args, +) + +MAPPER_DICT = { + 'id': '123-123', + 'name': 'fullname-ldap-mapper1', + 'providerId': 'full-name-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': {'read.only': ['false'], 'write.only': ['true'], 'ldap.full.name.attribute': ['cn']}, +} + + +WRONG_TYPE_MAPPER = deepcopy(MAPPER_DICT) +WRONG_TYPE_MAPPER.update({'providerId': 'role-ldap-mapper'}) + + +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 + + +CONNECTION_DICT = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper( + '{"access_token": "a long token"}' + ) +} + + +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 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): + try: + call_number = get_id_call_count.__next__() + except AttributeError: + # manage python 2 versions. + call_number = get_id_call_count.next() + return get_response(object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def test_mapper_name_without_federation_id_should_fail(monkeypatch): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'With mapper name, the federation_id or federation_uuid must be given.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['full_name_ldap_mapper'] + + +@pytest.fixture +def mock_absent_federation_url(mocker): + absent_federation = deepcopy(CONNECTION_DICT) + absent_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=does-not-exist': create_wrapper( + json.dumps([]) + ) + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), absent_federation), + autospec=True, + ) + + +def test_federation_does_not_exist_fail(monkeypatch, mock_absent_federation_url): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'does-not-exist', + 'mapper_name': 'does-not-exist-bis', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ( + ansible_exit_json['msg'] + == 'Cannot access mapper because does-not-exist federation does not exist.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['full_name_ldap_mapper'] + + +@pytest.fixture() +def mock_wrong_type(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=fullname-ldap-mapper1': create_wrapper( + json.dumps(WRONG_TYPE_MAPPER) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_good_name_but_wrong_type_should_raise_an_error(monkeypatch, mock_wrong_type): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'federation_id': 'my-company-ldap', + 'mapper_name': 'fullname-ldap-mapper1', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'fullname-ldap-mapper1 is not a full name mapper.' + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['full_name_ldap_mapper'] + + +@pytest.fixture() +def mock_existing_mapper(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=fullname-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': create_wrapper( + json.dumps(MAPPER_DICT) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +@pytest.mark.parametrize( + 'extra_arguments', + [ + {'mapper_name': 'fullname-ldap-mapper1', 'federation_id': 'my-company-ldap'}, + {'mapper_uuid': '123-123'}, + ], + ids=['mapper and federation names', 'mapper uuid'], +) +def test_present_group_mapper_without_properties_should_do_nothing( + monkeypatch, extra_arguments, mock_existing_mapper +): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + try: + given_id = extra_arguments['mapper_name'] + except KeyError: + given_id = extra_arguments['mapper_uuid'] + assert ansible_exit_json['msg'] == 'Full name mapper {} up to date, doing nothing.'.format( + given_id + ) + assert not ansible_exit_json['changed'] + assert ansible_exit_json['full_name_ldap_mapper'] == { + 'id': '123-123', + 'name': 'fullname-ldap-mapper1', + 'providerId': 'full-name-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + 'parentId': '456-456', + 'config': {'read.only': 'false', 'write.only': 'true', 'ldap.full.name.attribute': 'cn'}, + } + + +# Tests about delete is not done because already covered by other mapper. + + +@pytest.fixture() +def mock_update_url(mocker): + existing_federation = deepcopy(CONNECTION_DICT) + updated_mapper = deepcopy(MAPPER_DICT) + updated_mapper['config']['ldap.full.name.attribute'] = ['notCn'] + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=fullname-ldap-mapper1': create_wrapper( + json.dumps(MAPPER_DICT) + ), + 'http://keycloak.url/auth/admin/realms/master/components/123-123': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(updated_mapper)), + ], + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_state_present_should_update_existing_mapper(monkeypatch, mock_update_url): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'fullname-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'ldap_full_name_attribute': 'notCn', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert ansible_exit_json['msg'] == 'Full name mapper fullname-ldap-mapper1 updated.' + assert ansible_exit_json['changed'] + update_call_arguments = json.loads(mock_update_url.mock_calls[3][2]['data']) + reference_arguments = { + 'config': { + 'ldap.full.name.attribute': ['notCn'], + 'read.only': ['false'], + 'write.only': ['true'], + }, + 'name': 'fullname-ldap-mapper1', + 'parentId': '456-456', + 'providerId': 'full-name-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + assert update_call_arguments == reference_arguments + reference_arguments['id'] = '123-123' + assert ansible_exit_json['full_name_ldap_mapper'] == clean_payload_with_config( + reference_arguments + ) + + +@pytest.mark.parametrize( + 'extra_arguments, waited_error', + [({'read_only': True, 'write_only': True}, 'Cannot have read only and write only together')], + ids=['read only and write only'], +) +def test_incompatible_arguments_should_fail( + monkeypatch, mock_update_url, extra_arguments, waited_error +): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'fullname-ldap-mapper1', + 'federation_id': 'my-company-ldap', + } + arguments.update(extra_arguments) + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as exec_error: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = exec_error.value.args[0] + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['full_name_ldap_mapper'] + assert ansible_exit_json['msg'] == waited_error + + +@pytest.fixture() +def mock_create_url(mocker): + created_mapper = deepcopy(MAPPER_DICT) + created_mapper.pop('id') + existing_federation = deepcopy(CONNECTION_DICT) + existing_federation.update( + { + 'http://keycloak.url/auth/admin/realms/master/components?parent=master&type=org.keycloak.storage.UserStorageProvider&name=my-company-ldap': create_wrapper( + json.dumps( + { + 'id': '456-456', + 'name': 'my-company-ldap', + 'parentId': 'master', + 'config': {'pagination': [True]}, + } + ) + ), + 'http://keycloak.url/auth/admin/realms/master/components?parent=456-456&type=org.keycloak.storage.ldap.mappers.LDAPStorageMapper&name=fullname-ldap-mapper1': [ + create_wrapper(json.dumps({})), + create_wrapper(json.dumps(created_mapper)), + ], + 'http://keycloak.url/auth/admin/realms/master/components': create_wrapper( + json.dumps({}) + ), + } + ) + return mocker.patch( + 'ansible.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), existing_federation), + autospec=True, + ) + + +def test_present_state_should_create_absent_mapper(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'fullname-ldap-mapper1', + 'federation_id': 'my-company-ldap', + 'ldap_full_name_attribute': 'cn', + } + set_module_args(arguments) + + with pytest.raises(AnsibleExitJson) as ansible_stacktrace: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + reference_payload = { + 'config': { + 'ldap.full.name.attribute': ['cn'], + 'read.only': ['false'], + 'write.only': ['true'], + }, + 'name': 'fullname-ldap-mapper1', + 'parentId': '456-456', + 'providerId': 'full-name-ldap-mapper', + 'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper', + } + + assert ansible_exit_json['msg'] == 'Full name mapper fullname-ldap-mapper1 created.' + assert ansible_exit_json['changed'] + assert ansible_exit_json['full_name_ldap_mapper'] == clean_payload_with_config( + reference_payload + ) + create_call_arguments = json.loads(mock_create_url.mock_calls[3][2]['data']) + assert create_call_arguments == reference_payload + + +def test_check_mandatory_arguments_for_creation(monkeypatch, mock_create_url): + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'exit_json', exit_json) + monkeypatch.setattr(keycloak_full_name_ldap_mapper.AnsibleModule, 'fail_json', fail_json) + arguments = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'auth_realm': 'master', + 'realm': 'master', + 'mapper_name': 'fullname-ldap-mapper1', + 'federation_id': 'my-company-ldap', + } + set_module_args(arguments) + + with pytest.raises(AnsibleFailJson) as ansible_stacktrace: + keycloak_full_name_ldap_mapper.run_module() + ansible_exit_json = ansible_stacktrace.value.args[0] + assert ansible_exit_json['msg'] == ( + 'ldap_full_name_attribute is mandatory for full name mapper creation.' + ) + assert not ansible_exit_json['changed'] + assert not ansible_exit_json['full_name_ldap_mapper'] + +# Tests about no creation when absent is not done because already covered by other mapper. From 8d25c29e4ba3461516aa9933845ae180810fb400 Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Wed, 25 Sep 2019 17:22:31 +0200 Subject: [PATCH 78/79] fix: forgot during cherry-pick --- .../modules/identity/keycloak/keycloak_ldap_federation.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index d8ad530d060448..787d1ae3b67d34 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -644,8 +644,6 @@ def update(self, check=False): def _arguments_update_representation(self): clean_payload = clean_payload_with_config(self._create_payload(), credential_clean=False) payload_diff, _ = recursive_diff(clean_payload, self.initial_representation) - payload_diff.pop('providerId') - payload_diff.pop('providerType') if not payload_diff: return False return True From 288a36eaf66f7d94ad009618b047c62df43c3e2a Mon Sep 17 00:00:00 2001 From: "nicolas.duclert" Date: Thu, 26 Sep 2019 14:43:23 +0200 Subject: [PATCH 79/79] dev: ldap_federation: delete the import_enable parameter --- .../identity/keycloak/keycloak_ldap_federation.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py index 787d1ae3b67d34..10c9418c5b7553 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -157,12 +157,6 @@ type: str aliases: [ editMode ] - import_enable: - description: - - Whether to import the user from the LDAP into the Keycloak databases - type: bool - aliases: [ importEnable ] - synchronize_registrations: description: - Should new user in the Keycloak be created within the LDAP @@ -252,8 +246,10 @@ this module, I(importUser), I(connectionPooling) (and all associated parameters), I(allowKerberosAuthentication) (and all associated parameters), I(useKerberosForPasswordAuthentication), I(batchSize) and I(cachePolicy). - - The created federation will always be enabled. Adding this parameter in the payload bring - keycloak about making weird things (some parameters are not updated anymore). + - The created federation will always be enabled. Adding this parameter in the payload brings + keycloak making weird things (some parameters are not updated anymore). + - The created federation will always have the import user to true. Adding this parameter in the + payload brings keycloak making weird things (some parameters are not updated anymore). extends_documentation_fragment: - keycloak @@ -887,7 +883,6 @@ def run_module(): edit_mode=dict( type='str', choices=['READ_ONLY', 'UNSYNCED', 'WRITABLE'], aliases=['editMode'] ), - import_enable=dict(type='bool', aliases=['importEnable']), username_ldap_attribute=dict( type='str', aliases=['usernameLDAPAttribute', 'username_LDAP_attribute', 'usernameLdapAttribute'],