diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index ed08e1126fe6e7..bb18d41d5aac3a 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -694,8 +694,9 @@ files: labels: - clustering - k8s - $module_utils/keycloak.py: - maintainers: eikef + $module_utils/identity/keycloak/: + maintainers: eikef + support: community $module_utils/kubevirt.py: *kubevirt $module_utils/manageiq.py: maintainers: $team_manageiq 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/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/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.py b/lib/ansible/module_utils/identity/keycloak/keycloak.py new file mode 100644 index 00000000000000..4f7ef1ab2fd08e --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/keycloak.py @@ -0,0 +1,1192 @@ +# 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. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import absolute_import, division, print_function + +from ansible.module_utils._text import to_text +from copy import deepcopy + +__metaclass__ = type + +import json + +from ansible.module_utils.urls import open_url +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}" +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" +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}" + +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" + +URL_REALM = "{url}/admin/realms/{realm}" +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 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, + 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': 'modify', + 'POST': 'create' + } + if representation: + pushed_data = json.dumps(representation) + else: + pushed_data = {} + try: + return open_url(url, method=method, + headers= restheaders, + data=pushed_data, + validate_certs=validate_certs) + 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)))) + + +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 HTTPError 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 + + :return: argument_spec dict + """ + return dict( + auth_keycloak_url=dict(type='str', aliases=['url'], required=True), + auth_client_id=dict(type='str', default='admin-cli'), + auth_realm=dict(type='str', required=True), + auth_client_secret=dict(type='str', default=None), + auth_username=dict(type='str', aliases=['username'], required=True), + auth_password=dict(type='str', aliases=['password'], required=True, no_log=True), + validate_certs=dict(type='bool', default=True) + ) + + +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 KeycloakError(Exception): + pass + + +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: + 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): + """ 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, connection_header): + self.module = module + self.baseurl = self.module.params.get('auth_keycloak_url') + self.validate_certs = self.module.params.get('validate_certs') + self.restheaders = connection_header + + def get_clients(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 client with clientId specified in the filter is returned + :return: list of dicts of client representations + """ + clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + clientlist_url += '?clientId=%s' % filter + + try: + 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' + % (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_client_by_clientid(self, client_id, realm='master'): + """ Get client representation by clientId + :param client_id: The clientId to be queried + :param realm: realm from which to obtain the client representation + :return: dict with a client representation or None if none matching exist + """ + r = self.get_clients(realm=realm, filter=client_id) + if len(r) > 0: + return r[0] + else: + return None + + def get_client_by_id(self, id, realm='master'): + """ Obtain client representation by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of client representation or None if none matching exist + """ + 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, + validate_certs=self.validate_certs)) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain client %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 client %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' + % (id, realm, str(e))) + + def get_client_id(self, client_id, realm='master'): + """ Obtain id of client by client_id + + :param client_id: client_id of client to be queried + :param realm: client template from this realm + :return: id of client (usually a UUID) + """ + result = self.get_client_by_clientid(client_id, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def update_client(self, id, clientrep, realm="master"): + """ Update an existing client + :param id: id (not clientId) of client to be updated in Keycloak + :param clientrep: corresponding (partial/full) client representation with updates + :param realm: realm the client is in + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + 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' + % (id, realm, str(e))) + + def create_client(self, clientrep, realm="master"): + """ Create a client in keycloak + :param clientrep: Client representation of client to be created. Must at least contain field clientId + :param realm: realm for client to be created + :return: HTTPResponse object on success + """ + client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + + try: + 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' + % (clientrep['clientId'], realm, str(e))) + + def delete_client(self, id, realm="master"): + """ Delete a client from Keycloak + + :param id: id (not clientId) of client to be deleted + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + 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' + % (id, realm, str(e))) + + def get_client_templates(self, realm='master'): + """ Obtains client template representations for client templates in a realm + + :param realm: realm to be queried + :return: list of dicts of client representations + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + 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 list of client templates for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of client templates for realm %s: %s' + % (realm, str(e))) + + def get_client_template_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_CLIENTTEMPLATE.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 ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain client template %s for realm %s: %s' + % (id, realm, str(e))) + + def get_client_template_by_name(self, name, realm='master'): + """ Obtain client template representation by name + + :param name: 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 + """ + result = self.get_client_templates(realm) + if isinstance(result, list): + result = [x for x in result if x['name'] == name] + if len(result) > 0: + return result[0] + return None + + def get_client_template_id(self, name, realm='master'): + """ Obtain client template id by name + + :param name: name of client template to be queried + :param realm: client template from this realm + :return: client template id (usually a UUID) + """ + result = self.get_client_template_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def update_client_template(self, id, clienttrep, realm="master"): + """ Update an existing client template + :param id: id (not name) of client template to be updated in Keycloak + :param clienttrep: corresponding (partial/full) client template representation with updates + :param realm: realm the client template is in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + 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' + % (id, realm, str(e))) + + def create_client_template(self, clienttrep, realm="master"): + """ Create a client in keycloak + :param clienttrep: Client template representation of client template to be created. Must at least contain field name + :param realm: realm for client template to be created in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + 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' + % (clienttrep['clientId'], realm, str(e))) + + def delete_client_template(self, id, realm="master"): + """ Delete a client template from Keycloak + + :param id: id (not name) of client to be deleted + :param realm: realm of client template to be deleted + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + 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 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 + + :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 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 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) + user_representation = self._put_values_in_list(user_representation, ['credentials']) + + 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 + user_representation = self._put_values_in_list(user_representation, + ['keycloakAttributes', 'credentials']) + 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 + ) + + @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 + + :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_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_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))) 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..00d884fb8ada6c --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_federation.py @@ -0,0 +1,76 @@ +# 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 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.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.initial_representation['id'] + except KeyError: + 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 + """ + 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.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 + + :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 {} + + @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/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..395f70ac0acc64 --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/keycloak_ldap_mapper.py @@ -0,0 +1,151 @@ +# 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', + 'user attributes': 'user-attribute-ldap-mapper', + 'full name': 'full-name-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 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: + 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/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 new file mode 100644 index 00000000000000..70f5f89cf50c1c --- /dev/null +++ b/lib/ansible/module_utils/identity/keycloak/utils.py @@ -0,0 +1,69 @@ +# 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/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py deleted file mode 100644 index d4855edc8c3237..00000000000000 --- a/lib/ansible/module_utils/keycloak.py +++ /dev/null @@ -1,474 +0,0 @@ -# Copyright (c) 2017, Eike Frost -# -# This code is part of Ansible, but is an independent component. -# This particular file snippet, and this file snippet only, is BSD licensed. -# Modules you write using this snippet, which is embedded dynamically by Ansible -# still belong to the author of the module, and may assign their own license -# to the complete work. -# -# Redistribution and use in source and binary forms, with or without modification, -# are permitted provided that the following conditions are met: -# -# * Redistributions of source code must retain the above copyright -# notice, this list of conditions and the following disclaimer. -# * Redistributions in binary form must reproduce the above copyright notice, -# this list of conditions and the following disclaimer in the documentation -# and/or other materials provided with the distribution. -# -# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. -# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, -# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT -# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE -# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from __future__ import absolute_import, division, print_function - -__metaclass__ = type - -import json - -from ansible.module_utils.urls import open_url -from ansible.module_utils.six.moves.urllib.parse import urlencode -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_REALM_ROLES = "{url}/admin/realms/{realm}/roles" - -URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" -URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" -URL_GROUPS = "{url}/admin/realms/{realm}/groups" -URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" - - -def keycloak_argument_spec(): - """ - Returns argument_spec of options common to keycloak_*-modules - - :return: argument_spec dict - """ - return dict( - auth_keycloak_url=dict(type='str', aliases=['url'], required=True), - auth_client_id=dict(type='str', default='admin-cli'), - auth_realm=dict(type='str', required=True), - auth_client_secret=dict(type='str', default=None), - auth_username=dict(type='str', aliases=['username'], required=True), - auth_password=dict(type='str', aliases=['password'], required=True, no_log=True), - validate_certs=dict(type='bool', default=True) - ) - - -def camel(words): - return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) - - -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): - 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))) - - 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) - - def get_clients(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 client with clientId specified in the filter is returned - :return: list of dicts of client representations - """ - clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) - if filter is not None: - clientlist_url += '?clientId=%s' % filter - - try: - 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' - % (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_client_by_clientid(self, client_id, realm='master'): - """ Get client representation by clientId - :param client_id: The clientId to be queried - :param realm: realm from which to obtain the client representation - :return: dict with a client representation or None if none matching exist - """ - r = self.get_clients(realm=realm, filter=client_id) - if len(r) > 0: - return r[0] - else: - return None - - def get_client_by_id(self, id, realm='master'): - """ Obtain client representation by id - - :param id: id (not clientId) of client to be queried - :param realm: client from this realm - :return: dict of client representation or None if none matching exist - """ - 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, - validate_certs=self.validate_certs)) - - except HTTPError as e: - if e.code == 404: - return None - else: - self.module.fail_json(msg='Could not obtain client %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 client %s for realm %s: %s' - % (id, realm, str(e))) - except Exception as e: - self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' - % (id, realm, str(e))) - - def get_client_id(self, client_id, realm='master'): - """ Obtain id of client by client_id - - :param client_id: client_id of client to be queried - :param realm: client template from this realm - :return: id of client (usually a UUID) - """ - result = self.get_client_by_clientid(client_id, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None - - def update_client(self, id, clientrep, realm="master"): - """ Update an existing client - :param id: id (not clientId) of client to be updated in Keycloak - :param clientrep: corresponding (partial/full) client representation with updates - :param realm: realm the client is in - :return: HTTPResponse object on success - """ - client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) - - try: - 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' - % (id, realm, str(e))) - - def create_client(self, clientrep, realm="master"): - """ Create a client in keycloak - :param clientrep: Client representation of client to be created. Must at least contain field clientId - :param realm: realm for client to be created - :return: HTTPResponse object on success - """ - client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) - - try: - 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' - % (clientrep['clientId'], realm, str(e))) - - def delete_client(self, id, realm="master"): - """ Delete a client from Keycloak - - :param id: id (not clientId) of client to be deleted - :param realm: realm of client to be deleted - :return: HTTPResponse object on success - """ - client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) - - try: - 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' - % (id, realm, str(e))) - - def get_client_templates(self, realm='master'): - """ Obtains client template representations for client templates in a realm - - :param realm: realm to be queried - :return: list of dicts of client representations - """ - url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) - - 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 list of client templates for realm %s: %s' - % (realm, str(e))) - except Exception as e: - self.module.fail_json(msg='Could not obtain list of client templates for realm %s: %s' - % (realm, str(e))) - - def get_client_template_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_CLIENTTEMPLATE.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 ValueError as e: - self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' - % (id, realm, str(e))) - except Exception as e: - self.module.fail_json(msg='Could not obtain client template %s for realm %s: %s' - % (id, realm, str(e))) - - def get_client_template_by_name(self, name, realm='master'): - """ Obtain client template representation by name - - :param name: 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 - """ - result = self.get_client_templates(realm) - if isinstance(result, list): - result = [x for x in result if x['name'] == name] - if len(result) > 0: - return result[0] - return None - - def get_client_template_id(self, name, realm='master'): - """ Obtain client template id by name - - :param name: name of client template to be queried - :param realm: client template from this realm - :return: client template id (usually a UUID) - """ - result = self.get_client_template_by_name(name, realm) - if isinstance(result, dict) and 'id' in result: - return result['id'] - else: - return None - - def update_client_template(self, id, clienttrep, realm="master"): - """ Update an existing client template - :param id: id (not name) of client template to be updated in Keycloak - :param clienttrep: corresponding (partial/full) client template representation with updates - :param realm: realm the client template is in - :return: HTTPResponse object on success - """ - url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) - - try: - 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' - % (id, realm, str(e))) - - def create_client_template(self, clienttrep, realm="master"): - """ Create a client in keycloak - :param clienttrep: Client template representation of client template to be created. Must at least contain field name - :param realm: realm for client template to be created in - :return: HTTPResponse object on success - """ - url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) - - try: - 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' - % (clienttrep['clientId'], realm, str(e))) - - def delete_client_template(self, id, realm="master"): - """ Delete a client template from Keycloak - - :param id: id (not name) of client to be deleted - :param realm: realm of client template to be deleted - :return: HTTPResponse object on success - """ - url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) - - 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 client template %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 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))) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py index fe6984dae960cd..dba8a9d3c4c7bd 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, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -668,7 +669,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'), @@ -715,7 +715,19 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + 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') cid = module.params.get('id') 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..2a4adb7d01f320 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_client_scope.py @@ -0,0 +1,488 @@ +#!/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.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import( + KeycloakAPI, keycloak_argument_spec, check_role_representation, get_token, KeycloakError +) + + +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 + 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 + 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() diff --git a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py index 7bd0b927cdfd72..657acd965850b2 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, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -289,7 +290,19 @@ def main(): result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) # Obtain access token, initialize API - kc = KeycloakAPI(module) + 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') state = module.params.get('state') 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/lib/ansible/modules/identity/keycloak/keycloak_group.py b/lib/ansible/modules/identity/keycloak/keycloak_group.py index 0d6ba686b5d885..2a0b4725d2e396 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, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule @@ -235,7 +236,19 @@ def main(): result = dict(changed=False, msg='', diff={}, group='') # Obtain access token, initialize API - kc = KeycloakAPI(module) + 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') state = module.params.get('state') 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..89eaaea037de7f --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_group_role_mapping.py @@ -0,0 +1,316 @@ +#!/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.identity.keycloak.keycloak import ( + get_token, KeycloakAPI, keycloak_argument_spec, KeycloakError +) + + +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 = {} + 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')} + 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/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..10c9418c5b7553 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_federation.py @@ -0,0 +1,980 @@ +#!/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_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/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 + 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 has been tested against keycloak 6.0, the backward compatibility is not garanteed. + +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 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 + 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 ] + + 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 ] + + 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: + - 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 ] + + 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(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 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 + +author: + - Nicolas Duclert (@ndclt) +''' + +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 + federation_id: my-company-ldap + vendor: other + 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 +''' + +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 + changedUserSyncPeriod: + 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 +''' + +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, + get_token, + KeycloakError, + delete_on_url, + post_on_url, + put_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 +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 +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}' +TEST_LDAP_CONNECTION = '{url}/admin/realms/{realm}/testLDAPConnection' + +SEARCH_SCOPE = {'one level': 1, 'subtree': 2} + + +class LdapFederation(LdapFederationBase): + """Keycloak LDAP Federation class. + """ + + 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() + delete_on_url( + federation_url, self.restheaders, self.module, 'federation %s' % self.given_id + ) + + 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 clean_payload_with_config(federation_payload) + 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, + ) + if self.module.params.get('test_connection'): + self._test_connection() + 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, + ) + return clean_payload_with_config(federation_payload) + + 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) + if not payload_diff: + return False + return True + + def create(self): + """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'), + 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() + post_on_url( + post_url, + self.restheaders, + self.module, + 'federation %s' % self.given_id, + federation_payload, + ) + return clean_payload_with_config(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.' + % (self.module.params.get('connection_url')) + ) + + 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, ' + 'you should check your credentials.' + % (self.module.params.get('bind_dn'), self.module.params.get('connection_url')) + ) + + 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. + + 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 + works. + """ + try: + trust_store = self.initial_representation['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.initial_representation['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') + ) + headers = deepcopy(self.restheaders) + 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): + """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_translation = { + 'synchronize_registrations': 'syncRegistrations', + 'full_synchronization_period': 'fullSyncPeriod', + 'changed_user_synchronization_period': 'changedSyncPeriod', + 'batch_size_for_synchronization': 'batchSizeForSync', + } + config = {} + payload = { + 'providerId': 'ldap', + 'providerType': 'org.keycloak.storage.UserStorageProvider', + } + 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: + if key in list(translation.keys()): + payload.update({translation[key]: value}) + else: + if key in config_translation: + config.update({config_translation[key]: [value]}) + 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]}) + 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) + # 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, + 'batchSizeForSync': 1000, + } + payload.update( + {'config': if_absent_add_a_default_value(new_configuration, dict_of_default)} + ) + return payload + + def get_result(self): + """Get the payload cleaned of credentials and lists. + + :return: the cleaned payload + :rtype: dict + """ + return clean_payload_with_config(self._create_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', + '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']), + 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'] + ), + 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='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']), + 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']), + 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'] + ), + changed_user_synchronization_period=dict( + type='int', + aliases=[ + 'changedUserSyncPeriod', + 'changedUserSynchronizationPeriod', + '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']) + # 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'], + ], + ) + 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'), + ) + ldap_federation = LdapFederation(module, connection_header) + result = crud_with_instance(ldap_federation, 'ldap_federation') + except KeycloakError as e: + module.fail_json(msg=str(e), changed=False, ldap_federation={}) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() 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..e825d2eb8628d1 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_group_mapper.py @@ -0,0 +1,523 @@ +#!/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 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 + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this LDAP group 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 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 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.utils import ( + if_absent_add_a_default_value, +) +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 + +from ansible.module_utils.identity.keycloak.utils import snake_to_point_case, convert_to_bool + +USER_GROUP_RETRIEVE_STRATEGY_LABEL = 'user.roles.retrieve.strategy' + + +class FederationGroupMapper(FederationMapper): + def __init__(self, module, connection_header): + super(FederationGroupMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='group' + ) + + 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.initial_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 bracket.' + ) + + def create(self, check=False): + if not self.module.params.get('groups_dn'): + raise KeycloakError('groups_dn is mandatory for group mapper creation.') + super(FederationGroupMapper, self).create(check) + + +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'), + ) + federation_group_mapper = FederationGroupMapper(module, connection_header) + 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 main(): + run_module() + + +if __name__ == '__main__': + main() 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..b9f0c7f5d75b5f --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_role_mapper.py @@ -0,0 +1,493 @@ +#!/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_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 ansible.module_utils.identity.keycloak.keycloak import ( + KeycloakError, + keycloak_argument_spec, + get_token, +) +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(FederationMapper): + def __init__(self, module, connection_header): + super(FederationRoleMapper, self).__init__( + module=module, connection_header=connection_header, mapper_type='role' + ) + + 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 create(self, check=False): + if not self.module.params.get('roles_dn'): + raise KeycloakError('roles_dn is mandatory for role mapper creation.') + super(FederationRoleMapper, 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']), + 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 + ), + use_realm_roles_mapping=dict(type=bool), + client_id=dict(type=str), + ) + 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/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..0bfe3c5a1658b7 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_ldap_synchronization.py @@ -0,0 +1,285 @@ +#!/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_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.10" + +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 +''' + +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, +) +from ansible.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, + get_token, + post_on_url, + KeycloakError, +) + +ALL_OPERATIONS = ['synchronize_changed_users', 'synchronize_all_users', 'remove_imported', 'unlink_users'] + + +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 = '' + 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): + """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: + synchronisation_result = json.load(response) + except JSONDecodeError: + # 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): + """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'] + '.' + 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): + """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: + 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, + ], + ) + 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=err) + 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/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/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..2e45cad8f6a195 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_link_user_to_group.py @@ -0,0 +1,368 @@ +#!/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 user and group and 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_user +- 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, + 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 + + +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', + ) + 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'], + ], + ) + 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') + result = {} + + if not link_user_to_group.are_user_and_group_linked() and state == 'absent': + 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'] = 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': + 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'] = 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 + link_user_to_group.delete_link() + else: + result['changed'] = False + result['link_user_to_group'] = {} + 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) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() 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..fbd07552dbbf61 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_realm.py @@ -0,0 +1,1074 @@ +#!/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.basic import AnsibleModule +from ansible.module_utils.identity.keycloak.keycloak import ( + get_token, KeycloakAPI, camel, keycloak_argument_spec, KeycloakError +) + + +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 + 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') + 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() 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..f177749a56c3af --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_role.py @@ -0,0 +1,457 @@ +#!/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 copy import deepcopy + +from ansible.module_utils.common.dict_transformations import recursive_diff + +__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.identity.keycloak.keycloak import ( + KeycloakAPI, camel, keycloak_argument_spec, get_token, KeycloakError, +) +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')) + + 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) + + 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': + 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) + + +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=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: + 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/lib/ansible/modules/identity/keycloak/keycloak_user.py b/lib/ansible/modules/identity/keycloak/keycloak_user.py new file mode 100644 index 00000000000000..ef2e4e81b7ff40 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_user.py @@ -0,0 +1,455 @@ +#!/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 + type: str + + realm: + description: + - The realm to create the user in. + default: master + type: str + + 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 + 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: + - 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 + type: str + + 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 ] + 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 + +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} + credentials: {'type': 'password', 'value': 'userTest1secret'} +''' + +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.identity.keycloak.keycloak import ( + KeycloakAPI, camel, keycloak_argument_spec, get_token, KeycloakError +) +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', 'value']: + 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), + credentials=dict(type='dict'), + ) + + 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')) + + 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) + + 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 sanitize_user_representation(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/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..3ad79f650bd1a2 --- /dev/null +++ b/test/units/module_utils/identity/keycloak/test_keycloak_connect.py @@ -0,0 +1,168 @@ +from __future__ import (absolute_import, division, print_function) + +import pytest +from itertools import count + +from ansible.module_utils.identity.keycloak.keycloak import ( + get_token, + 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 = get_token( + 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 == { + '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: + get_token( + 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 + ) + # 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: ' + ) in str(raised_error.value) + + +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: + get_token( + 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: + get_token( + 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' + ) 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_group_role_mapping.py b/test/units/modules/identity/keycloak/test_group_role_mapping.py new file mode 100644 index 00000000000000..f21504cc3ebe30 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_group_role_mapping.py @@ -0,0 +1,351 @@ +# -*- 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 + + +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.identity.keycloak.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.identity.keycloak.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.identity.keycloak.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.identity.keycloak.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.identity.keycloak.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_kecyloak_ldap_group_mapper.py b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py new file mode 100644 index 00000000000000..a14e8455030110 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_kecyloak_ldap_group_mapper.py @@ -0,0 +1,619 @@ +# -*- 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) + 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( + 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({})), + 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_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 + reference_arguments['id'] = '123-123' + 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 bracket.', + ), + ], + 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): + 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( + { + '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({})), + 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_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=my-company,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=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'], + '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'] 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. 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..02fde5bfaf9ecc --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_federation.py @@ -0,0 +1,717 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import json +from copy import deepcopy +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.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 = 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' + ), + } + ) + 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', + [{'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 +): + 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 = 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( + 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), + 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 +): + 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 = 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_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), + autospec=True, + ) + + +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 = { + '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, + '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', + } + 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']}) + elif value is None: + send_config.update({key: []}) + else: + send_config.update({key: [value]}) + reference_result.update({'config': send_config}) + 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 + + +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 = { + '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_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(), check_connectivity), + 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, mock_check_connectivity +): + 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_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 + 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): + 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' + ), + 'wrong credentials': [ + None, + 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', + 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(), check_connection), + 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 +): + 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 = 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, + 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), + autospec=True, + ) + + +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 = { + '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': { + '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', + 'connectionPooling': False, + 'cachePolicy': 'DEFAULT', + }, + '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): + 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_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) + 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 + + +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.""" + 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://keycloak.url/auth', + 'auth_realm': 'master', + 'auth_username': 'test_admin', + 'auth_password': 'admin_password', + 'realm': 'master', + 'federation_id': 'company-ldap', + 'state': 'present', + 'synchronize_registrations': True, + } + set_module_args(arguments) + with pytest.raises(AnsibleExitJson): + keycloak_ldap_federation.run_module() + 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', + ] + for one_key in mandatory_keys_for_sync: + assert one_key in all_config_keys + assert config['syncRegistrations'] == [True] + assert config['priority'] == [3] 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'] == {} 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..c728339f00d860 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_ldap_synchronisation.py @@ -0,0 +1,268 @@ +# -*- 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, + fail_json, + exit_json, + set_module_args, +) + + +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_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': create_wrapper( + '' + ), + 'http://keycloak.url/auth/admin/realms/master/user-storage/123-123/unlink-users': create_wrapper( + '' + ), + } + ) + 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.' + ) 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'] 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) 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..a4c944a58d7616 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_role.py @@ -0,0 +1,432 @@ +# -*- 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.identity.keycloak.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_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.identity.keycloak.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() + 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.identity.keycloak.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.identity.keycloak.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.identity.keycloak.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) 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..690c21c7279238 --- /dev/null +++ b/test/units/modules/identity/keycloak/test_keycloak_user.py @@ -0,0 +1,403 @@ +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.identity.keycloak.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.identity.keycloak.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', + 'credentials': {'type': 'password', 'value': 'user1_secret'} + } + 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) + assert ansible_exit_json['proposed']['credentials']['value'] == 'no_log' + + +@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.identity.keycloak.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()