diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py new file mode 100644 index 0000000..15b6657 --- /dev/null +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -0,0 +1,2192 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Eike Frost +# BSD 2-Clause license (see LICENSES/BSD-2-Clause.txt) +# SPDX-License-Identifier: BSD-2-Clause + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +import traceback + +from ansible.module_utils.urls import open_url +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.common.text.converters import to_native, to_text + +URL_REALM_INFO = "{url}/realms/{realm}" +URL_REALMS = "{url}/admin/realms" +URL_REALM = "{url}/admin/realms/{realm}" + +URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" +URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" +URL_CLIENTS = "{url}/admin/realms/{realm}/clients" + +URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" +URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" +URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" + +URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" +URL_REALM_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_REALM_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/available" +URL_REALM_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm/composite" +URL_REALM_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/roles/{name}/composites" + +URL_ROLES_BY_ID = "{url}/admin/realms/{realm}/roles-by-id/{id}" +URL_ROLES_BY_ID_COMPOSITES_CLIENTS = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites/clients/{cid}" +URL_ROLES_BY_ID_COMPOSITES = "{url}/admin/realms/{realm}/roles-by-id/{id}/composites" + +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_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children" + +URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes" +URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" +URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" +URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" + +URL_CLIENT_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}" +URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available" +URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite" + +URL_USERS = "{url}/admin/realms/{realm}/users" +URL_CLIENT_SERVICE_ACCOUNT_USER = "{url}/admin/realms/{realm}/clients/{id}/service-account-user" +URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}" +URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" +URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/composite" + +URL_CLIENTSECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret" + +URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows" +URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}" +URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy" +URL_AUTHENTICATION_FLOW_EXECUTIONS = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions" +URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/execution" +URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{flowalias}/executions/flow" +URL_AUTHENTICATION_EXECUTION_CONFIG = "{url}/admin/realms/{realm}/authentication/executions/{id}/config" +URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/raise-priority" +URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/lower-priority" +URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" + +URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances" +URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" +URL_IDENTITY_PROVIDER_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers" +URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}" + +URL_COMPONENTS = "{url}/admin/realms/{realm}/components" +URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" + + +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, no_log=False), + auth_client_id=dict(type='str', default='admin-cli'), + auth_realm=dict(type='str'), + auth_client_secret=dict(type='str', default=None, no_log=True), + auth_username=dict(type='str', aliases=['username']), + auth_password=dict(type='str', aliases=['password'], no_log=True), + validate_certs=dict(type='bool', default=True), + connection_timeout=dict(type='int', default=10), + token=dict(type='str', no_log=True), + http_agent=dict(type='str', default='Ansible'), + ) + + +def camel(words): + return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) + + +class KeycloakError(Exception): + pass + + +def get_token(module_params): + """ Obtains connection header with token for the authentication, + token already given or obtained from credentials + :param module_params: parameters of the module + :return: connection header + """ + token = module_params.get('token') + base_url = module_params.get('auth_keycloak_url') + http_agent = module_params.get('http_agent') + + if not base_url.lower().startswith(('http', 'https')): + raise KeycloakError("auth_url '%s' should either start with 'http' or 'https'." % base_url) + + if token is None: + 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') + connection_timeout = module_params.get('connection_timeout') + 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.loads(to_native(open_url(auth_url, method='POST', + validate_certs=validate_certs, http_agent=http_agent, timeout=connection_timeout, + data=urlencode(payload)).read())) + 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: + token = r['access_token'] + except KeyError: + raise KeycloakError( + 'Could not obtain access token from %s' % auth_url) + return { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + } + + +def is_struct_included(struct1, struct2, exclude=None): + """ + This function compare if the first parameter structure is included in the second. + The function use every elements of struct1 and validates they are present in the struct2 structure. + The two structure does not need to be equals for that function to return true. + Each elements are compared recursively. + :param struct1: + type: + dict for the initial call, can be dict, list, bool, int or str for recursive calls + description: + reference structure + :param struct2: + type: + dict for the initial call, can be dict, list, bool, int or str for recursive calls + description: + structure to compare with first parameter. + :param exclude: + type: + list + description: + Key to exclude from the comparison. + default: None + :return: + type: + bool + description: + Return True if all element of dict 1 are present in dict 2, return false otherwise. + """ + if isinstance(struct1, list) and isinstance(struct2, list): + for item1 in struct1: + if isinstance(item1, (list, dict)): + for item2 in struct2: + if not is_struct_included(item1, item2, exclude): + return False + else: + if item1 not in struct2: + return False + return True + elif isinstance(struct1, dict) and isinstance(struct2, dict): + try: + for key in struct1: + if not (exclude and key in exclude): + if not is_struct_included(struct1[key], struct2[key], exclude): + return False + return True + except KeyError: + return False + elif isinstance(struct1, bool) and isinstance(struct2, bool): + return struct1 == struct2 + else: + return to_text(struct1, 'utf-8') == to_text(struct2, 'utf-8') + + +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.connection_timeout = self.module.params.get('connection_timeout') + self.restheaders = connection_header + self.http_agent = self.module.params.get('http_agent') + + def get_realm_info_by_id(self, realm='master'): + """ Obtain realm public info by id + + :param realm: realm id + :return: dict of real, representation or None if none matching exist + """ + realm_info_url = URL_REALM_INFO.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_info_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + + def get_realm_by_id(self, realm='master'): + """ Obtain realm representation by id + + :param realm: realm id + :return: dict of real, representation or None if none matching exist + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + + def update_realm(self, realmrep, realm="master"): + """ Update an existing realm + :param realmrep: corresponding (partial/full) realm representation with updates + :param realm: realm to be updated in Keycloak + :return: HTTPResponse object on success + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return open_url(realm_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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, str(e)), + exception=traceback.format_exc()) + + def create_realm(self, realmrep): + """ Create a realm in keycloak + :param realmrep: Realm representation of realm to be created. + :return: HTTPResponse object on success + """ + realm_url = URL_REALMS.format(url=self.baseurl) + + try: + return open_url(realm_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(realmrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create realm %s: %s' % (realmrep['id'], str(e)), + exception=traceback.format_exc()) + + def delete_realm(self, realm="master"): + """ Delete a realm from Keycloak + + :param realm: realm to be deleted + :return: HTTPResponse object on success + """ + realm_url = URL_REALM.format(url=self.baseurl, realm=realm) + + try: + return open_url(realm_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + + 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.loads(to_native(open_url(clientlist_url, http_agent=self.http_agent, method='GET', headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of 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.loads(to_native(open_url(client_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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_roles_by_id(self, cid, realm="master"): + """ Fetch the roles of the a client on the Keycloak server. + + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for client %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_client_role_id_by_name(self, cid, name, realm="master"): + """ Get the role ID of a client. + + :param cid: ID of the client from which to obtain the rolemappings. + :param name: Name of the role. + :param realm: Realm from which to obtain the rolemappings. + :return: The ID of the role, None if not found. + """ + rolemappings = self.get_client_roles_by_id(cid, realm=realm) + for role in rolemappings: + if name == role['name']: + return role['id'] + return None + + def get_client_group_rolemapping_by_id(self, gid, cid, rid, realm='master'): + """ Obtain client representation by id + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + return None + + def get_client_group_available_rolemappings(self, gid, cid, realm="master"): + """ Fetch the available role of a client in a specified goup on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_client_group_composite_rolemappings(self, gid, cid, realm="master"): + """ Fetch the composite role of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + composite_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_role_by_id(self, rid, realm="master"): + """ Fetch a role by its id on the Keycloak server. + + :param rid: ID of the role. + :param realm: Realm from which to obtain the rolemappings. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID.format(url=self.baseurl, realm=realm, id=rid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch role for id %s in realm %s: %s" + % (rid, realm, str(e))) + + def get_client_roles_by_id_composite_rolemappings(self, rid, cid, realm="master"): + """ Fetch a role by its id on the Keycloak server. + + :param rid: ID of the composite role. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The role. + """ + client_roles_url = URL_ROLES_BY_ID_COMPOSITES_CLIENTS.format(url=self.baseurl, realm=realm, id=rid, cid=cid) + try: + return json.loads(to_native(open_url(client_roles_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch role for id %s and cid %s in realm %s: %s" + % (rid, cid, realm, str(e))) + + def add_client_roles_by_id_composite_rolemapping(self, rid, roles_rep, realm="master"): + """ Assign roles to composite role + + :param rid: ID of the composite role. + :param roles_rep: Representation of the roles to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_ROLES_BY_ID_COMPOSITES.format(url=self.baseurl, realm=realm, id=rid) + try: + open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(roles_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not assign roles to composite role %s and realm %s: %s" + % (rid, realm, str(e))) + + def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): + """ Fetch the composite role of a client in a specified goup on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + open_url(available_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def delete_group_rolemapping(self, gid, cid, role_rep, realm="master"): + """ Delete the rolemapping of a client in a specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + available_rolemappings_url = URL_CLIENT_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=gid, client=cid) + try: + open_url(available_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not delete available rolemappings for client %s in group %s, realm %s: %s" + % (cid, gid, realm, str(e))) + + def get_client_user_rolemapping_by_id(self, uid, cid, rid, realm='master'): + """ Obtain client representation by id + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for client %s and user %s, realm %s: %s" + % (cid, uid, realm, str(e))) + return None + + def get_client_user_available_rolemappings(self, uid, cid, realm="master"): + """ Fetch the available role of a client for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The effective rollemappings of specified client and user of the realm (default "master"). + """ + available_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch effective rolemappings for client %s and user %s, realm %s: %s" + % (cid, uid, realm, str(e))) + + def get_client_user_composite_rolemappings(self, uid, cid, realm="master"): + """ Fetch the composite role of a client for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + composite_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for user %s of realm %s: %s" + % (uid, realm, str(e))) + + def get_realm_user_rolemapping_by_id(self, uid, rid, realm='master'): + """ Obtain role representation by id + + :param uid: ID of the user from which to obtain the rolemappings. + :param rid: ID of the role. + :param realm: client from this realm + :return: dict of rolemapping representation or None if none matching exist + """ + rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + rolemappings = json.loads(to_native(open_url(rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + for role in rolemappings: + if rid == role['id']: + return role + except Exception as e: + self.module.fail_json(msg="Could not fetch rolemappings for user %s, realm %s: %s" + % (uid, realm, str(e))) + return None + + def get_realm_user_available_rolemappings(self, uid, realm="master"): + """ Fetch the available role of a realm for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The rollemappings of specified group and client of the realm (default "master"). + """ + available_rolemappings_url = URL_REALM_ROLEMAPPINGS_AVAILABLE.format(url=self.baseurl, realm=realm, id=uid) + try: + return json.loads(to_native(open_url(available_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch available rolemappings for user %s of realm %s: %s" + % (uid, realm, str(e))) + + def get_realm_user_composite_rolemappings(self, uid, realm="master"): + """ Fetch the composite role of a realm for a specified user on the Keycloak server. + + :param uid: ID of the user from which to obtain the rolemappings. + :param realm: Realm from which to obtain the rolemappings. + :return: The effective rollemappings of specified client and user of the realm (default "master"). + """ + composite_rolemappings_url = URL_REALM_ROLEMAPPINGS_COMPOSITE.format(url=self.baseurl, realm=realm, id=uid) + try: + return json.loads(to_native(open_url(composite_rolemappings_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch effective rolemappings for user %s, realm %s: %s" + % (uid, realm, str(e))) + + def get_user_by_username(self, username, realm="master"): + """ Fetch a keycloak user within a realm based on its username. + + If the user does not exist, None is returned. + :param username: Username of the user to fetch. + :param realm: Realm in which the user resides; default 'master' + """ + users_url = URL_USERS.format(url=self.baseurl, realm=realm) + users_url += '?username=%s&exact=true' % username + try: + return json.loads(to_native(open_url(users_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the user for realm %s and username %s: %s' + % (realm, username, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain the user for realm %s and username %s: %s' + % (realm, username, str(e))) + + def get_service_account_user_by_client_id(self, client_id, realm="master"): + """ Fetch a keycloak service account user within a realm based on its client_id. + + If the user does not exist, None is returned. + :param client_id: clientId of the service account user to fetch. + :param realm: Realm in which the user resides; default 'master' + """ + cid = self.get_client_id(client_id, realm=realm) + + service_account_user_url = URL_CLIENT_SERVICE_ACCOUNT_USER.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(service_account_user_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain the service-account-user for realm %s and client_id %s: %s' + % (realm, client_id, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain the service-account-user for realm %s and client_id %s: %s' + % (realm, client_id, str(e))) + + def add_user_rolemapping(self, uid, cid, role_rep, realm="master"): + """ Assign a realm or client role to a specified user on the Keycloak server. + + :param uid: ID of the user roles are assigned to. + :param cid: ID of the client from which to obtain the rolemappings. If empty, roles are from the realm + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + open_url(user_realm_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not map roles to userId %s for realm %s and roles %s: %s" + % (uid, realm, json.dumps(role_rep), str(e))) + else: + user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + open_url(user_client_rolemappings_url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not map roles to userId %s for client %s, realm %s and roles %s: %s" + % (cid, uid, realm, json.dumps(role_rep), str(e))) + + def delete_user_rolemapping(self, uid, cid, role_rep, realm="master"): + """ Delete the rolemapping of a client in a specified user on the Keycloak server. + + :param uid: ID of the user from which to remove the rolemappings. + :param cid: ID of the client from which to remove the rolemappings. + :param role_rep: Representation of the role to remove from rolemappings. + :param realm: Realm from which to remove the rolemappings. + :return: None. + """ + if cid is None: + user_realm_rolemappings_url = URL_REALM_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid) + try: + open_url(user_realm_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not remove roles %s from userId %s, realm %s: %s" + % (json.dumps(role_rep), uid, realm, str(e))) + else: + user_client_rolemappings_url = URL_CLIENT_USER_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, id=uid, client=cid) + try: + open_url(user_client_rolemappings_url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.module.fail_json(msg="Could not remove roles %s for client %s from userId %s, realm %s: %s" + % (json.dumps(role_rep), cid, uid, 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.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of 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.loads(to_native(open_url(url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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_clientscopes(self, realm="master"): + """ Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param realm: Realm in which the clientscope resides; default 'master'. + :return The clientscopes of this realm (default "master") + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of clientscopes in realm %s: %s" + % (realm, str(e))) + + def get_clientscope_by_clientscopeid(self, cid, realm="master"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(clientscope_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + % (cid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_clientscope_by_name(self, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param name: Name of the clientscope to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_clientscopes = self.get_clientscopes(realm=realm) + + for clientscope in all_clientscopes: + if clientscope['name'] == name: + return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope(self, clientscoperep, realm="master"): + """ Create a Keycloak clientscope. + + :param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm) + try: + return open_url(clientscopes_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create clientscope %s in realm %s: %s" + % (clientscoperep['name'], realm, str(e))) + + def update_clientscope(self, clientscoperep, realm="master"): + """ Update an existing clientscope. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id']) + + try: + return open_url(clientscope_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(clientscoperep), validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg='Could not update clientscope %s in realm %s: %s' + % (clientscoperep['name'], realm, str(e))) + + def delete_clientscope(self, name=None, cid=None, realm="master"): + """ Delete a clientscope. One of name or cid must be provided. + + Providing the clientscope ID is preferred as it avoids a second lookup to + convert a clientscope name to an ID. + + :param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID. + :param cid: The ID of the clientscope (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if cid 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 cid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if cid is None and name is not None: + for clientscope in self.get_clientscopes(realm=realm): + if clientscope['name'] == name: + cid = clientscope['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if cid is None: + return None + + # should have a good cid by here. + clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl) + try: + return open_url(clientscope_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg="Unable to delete clientscope %s: %s" % (cid, str(e))) + + def get_clientscope_protocolmappers(self, cid, realm="master"): + """ Fetch the name and ID of all clientscopes on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return. + + :param cid: id of clientscope (not name). + :param realm: Realm in which the clientscope resides; default 'master'. + :return The protocolmappers of this realm (default "master") + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(protocolmappers_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of protocolmappers in realm %s: %s" + % (realm, str(e))) + + def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"): + """ Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID. + + If the clientscope does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + + :param cid: UUID of the protocolmapper to be returned + :param cid: UUID of the clientscope to be returned + :param realm: Realm in which the clientscope resides; default 'master'. + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid) + try: + return json.loads(to_native(open_url(protocolmapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (pid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (cid, realm, str(e))) + + def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"): + """ Fetch a keycloak clientscope within a realm based on its name. + + The Keycloak API does not allow filtering of the clientscopes resource by name. + As a result, this method first retrieves the entire list of clientscopes - name and ID - + then performs a second query to fetch the group. + + If the clientscope does not exist, None is returned. + :param cid: Id of the clientscope (not name). + :param name: Name of the protocolmapper to fetch. + :param realm: Realm in which the clientscope resides; default 'master' + """ + try: + all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm) + + for protocolmapper in all_protocolmappers: + if protocolmapper['name'] == name: + return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s" + % (name, realm, str(e))) + + def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"): + """ Create a Keycloak clientscope protocolmapper. + + :param cid: Id of the clientscope. + :param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm) + try: + return open_url(protocolmappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create protocolmapper %s in realm %s: %s" + % (mapper_rep['name'], realm, str(e))) + + def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"): + """ Update an existing clientscope. + + :param cid: Id of the clientscope. + :param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper. + :return HTTPResponse object on success + """ + protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id']) + + try: + return open_url(protocolmapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper_rep), validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s' + % (mapper_rep, realm, str(e))) + + def create_clientsecret(self, id, realm="master"): + """ Generate a new client secret by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.loads(to_native(open_url(clientsecret_url, method='POST', headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' + % (id, realm, str(e))) + + def get_clientsecret(self, id, realm="master"): + """ Obtain client secret by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of credential representation + """ + clientsecret_url = URL_CLIENTSECRET.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.loads(to_native(open_url(clientsecret_url, method='GET', headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain clientsecret of client %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain clientsecret of client %s for 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.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not 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.loads(to_native(open_url(groups_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch 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", parents=None): + """ 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' + :param parents: Optional list of parents when group to look for is a subgroup + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + if parents: + parent = self.get_subgroup_direct_parent(parents, realm) + + if not parent: + return None + + all_groups = parent['subGroups'] + else: + 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_normed_group_parent(self, parent): + """ Converts parent dict information into a more easy to use form. + + :param parent: parent describing dict + """ + if parent['id']: + return (parent['id'], True) + + return (parent['name'], False) + + def get_subgroup_by_chain(self, name_chain, realm="master"): + """ Access a subgroup API object by walking down a given name/id chain. + + Groups can be given either as by name or by ID, the first element + must either be a toplvl group or given as ID, all parents must exist. + + If the group cannot be found, None is returned. + :param name_chain: Topdown ordered list of subgroup parent (ids or names) + its own name at the end + :param realm: Realm in which the group resides; default 'master' + """ + cp = name_chain[0] + + # for 1st parent in chain we must query the server + cp, is_id = self._get_normed_group_parent(cp) + + if is_id: + tmp = self.get_group_by_groupid(cp, realm=realm) + else: + # given as name, assume toplvl group + tmp = self.get_group_by_name(cp, realm=realm) + + if not tmp: + return None + + for p in name_chain[1:]: + for sg in tmp['subGroups']: + pv, is_id = self._get_normed_group_parent(p) + + if is_id: + cmpkey = "id" + else: + cmpkey = "name" + + if pv == sg[cmpkey]: + tmp = sg + break + + if not tmp: + return None + + return tmp + + def get_subgroup_direct_parent(self, parents, realm="master", children_to_resolve=None): + """ Get keycloak direct parent group API object for a given chain of parents. + + To succesfully work the API for subgroups we actually dont need + to "walk the whole tree" for nested groups but only need to know + the ID for the direct predecessor of current subgroup. This + method will guarantee us this information getting there with + as minimal work as possible. + + Note that given parent list can and might be incomplete at the + upper levels as long as it starts with an ID instead of a name + + If the group does not exist, None is returned. + :param parents: Topdown ordered list of subgroup parents + :param realm: Realm in which the group resides; default 'master' + """ + if children_to_resolve is None: + # start recursion by reversing parents (in optimal cases + # we dont need to walk the whole tree upwarts) + parents = list(reversed(parents)) + children_to_resolve = [] + + if not parents: + # walk complete parents list to the top, all names, no id's, + # try to resolve it assuming list is complete and 1st + # element is a toplvl group + return self.get_subgroup_by_chain(list(reversed(children_to_resolve)), realm=realm) + + cp = parents[0] + unused, is_id = self._get_normed_group_parent(cp) + + if is_id: + # current parent is given as ID, we can stop walking + # upwards searching for an entry point + return self.get_subgroup_by_chain([cp] + list(reversed(children_to_resolve)), realm=realm) + else: + # current parent is given as name, it must be resolved + # later, try next parent (recurse) + children_to_resolve.append(cp) + return self.get_subgroup_direct_parent( + parents[1:], + realm=realm, children_to_resolve=children_to_resolve + ) + + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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 create_subgroup(self, parents, grouprep, realm="master"): + """ Create a Keycloak subgroup. + + :param parents: list of one or more parent groups + :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + parent_id = "---UNDETERMINED---" + try: + parent_id = self.get_subgroup_direct_parent(parents, realm) + + if not parent_id: + raise Exception( + "Could not determine subgroup parent ID for given" + " parent chain {0}. Assure that all parents exist" + " already and the list is complete and properly" + " ordered, starts with an ID or starts at the" + " top level".format(parents) + ) + + parent_id = parent_id["id"] + url = URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent_id) + return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create subgroup %s for parent group %s in realm %s: %s" + % (grouprep['name'], parent_id, 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + 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', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) + + def get_realm_roles(self, realm='master'): + """ Obtains role representations for roles in a realm + + :param realm: realm to be queried + :return: list of dicts of role representations + """ + rolelist_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of roles for realm %s: %s' + % (realm, str(e))) + + def get_realm_role(self, name, realm='master'): + """ Fetch a keycloak role from the provided realm using the role's name. + + If the role does not exist, None is returned. + :param name: Name of the role to fetch. + :param realm: Realm in which the role resides; default 'master'. + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name)) + try: + return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' + % (name, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch role %s in realm %s: %s' + % (name, realm, str(e))) + + def create_realm_role(self, rolerep, realm='master'): + """ Create a Keycloak realm role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + try: + return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create role %s in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def update_realm_role(self, rolerep, realm='master'): + """ Update an existing realm role. + + :param rolerep: A RoleRepresentation of the updated role. + :return HTTPResponse object on success + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name'])) + try: + return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update role %s in realm %s: %s' + % (rolerep['name'], realm, str(e))) + + def delete_realm_role(self, name, realm='master'): + """ Delete a realm role. + + :param name: The name of the role. + :param realm: The realm in which this role resides, default "master". + """ + role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(name)) + try: + return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete role %s in realm %s: %s' + % (name, realm, str(e))) + + def get_client_roles(self, clientid, realm='master'): + """ Obtains role representations for client roles in a specific client + + :param clientid: Client id to be queried + :param realm: Realm to be queried + :return: List of dicts of role representations + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + rolelist_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(rolelist_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of roles for client %s in realm %s: %s' + % (clientid, realm, str(e))) + + def get_client_role(self, name, clientid, realm='master'): + """ Fetch a keycloak client role from the provided realm using the role's name. + + :param name: Name of the role to fetch. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: Dict of role representation + If the role does not exist, None is returned. + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name)) + try: + return json.loads(to_native(open_url(role_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not fetch role %s in client %s of realm %s: %s' + % (name, clientid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch role %s for client %s in realm %s: %s' + % (name, clientid, realm, str(e))) + + def create_client_role(self, rolerep, clientid, realm='master'): + """ Create a Keycloak client role. + + :param rolerep: a RoleRepresentation of the role to be created. Must contain at minimum the field name. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return: HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) + try: + return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s' + % (rolerep['name'], clientid, realm, str(e))) + + def update_client_role(self, rolerep, clientid, realm="master"): + """ Update an existing client role. + + :param rolerep: A RoleRepresentation of the updated role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + :return HTTPResponse object on success + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'])) + try: + return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(rolerep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s' + % (rolerep['name'], clientid, realm, str(e))) + + def delete_client_role(self, name, clientid, realm="master"): + """ Delete a role. One of name or roleid must be provided. + + :param name: The name of the role. + :param clientid: Client id for the client role + :param realm: Realm in which the role resides + """ + cid = self.get_client_id(clientid, realm=realm) + if cid is None: + self.module.fail_json(msg='Could not find client %s in realm %s' + % (clientid, realm)) + role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(name)) + try: + return open_url(role_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete role %s for client %s in realm %s: %s' + % (name, clientid, realm, str(e))) + + def get_authentication_flow_by_alias(self, alias, realm='master'): + """ + Get an authentication flow by it's alias + :param alias: Alias of the authentication flow to get. + :param realm: Realm. + :return: Authentication flow representation. + """ + try: + authentication_flow = {} + # Check if the authentication flow exists on the Keycloak serveraders + authentications = json.load(open_url(URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, realm=realm), method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, validate_certs=self.validate_certs)) + for authentication in authentications: + if authentication["alias"] == alias: + authentication_flow = authentication + break + return authentication_flow + except Exception as e: + self.module.fail_json(msg="Unable get authentication flow %s: %s" % (alias, str(e))) + + def delete_authentication_flow_by_id(self, id, realm='master'): + """ + Delete an authentication flow from Keycloak + :param id: id of authentication flow to be deleted + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + flow_url = URL_AUTHENTICATION_FLOW.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(flow_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete authentication flow %s in realm %s: %s' + % (id, realm, str(e))) + + def copy_auth_flow(self, config, realm='master'): + """ + Create a new authentication flow from a copy of another. + :param config: Representation of the authentication flow to create. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + new_name = dict( + newName=config["alias"] + ) + open_url( + URL_AUTHENTICATION_FLOW_COPY.format( + url=self.baseurl, + realm=realm, + copyfrom=quote(config["copyFrom"])), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(new_name), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + flow_list = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, + realm=realm), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for flow in flow_list: + if flow["alias"] == config["alias"]: + return flow + return None + except Exception as e: + self.module.fail_json(msg='Could not copy authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def create_empty_auth_flow(self, config, realm='master'): + """ + Create a new empty authentication flow. + :param config: Representation of the authentication flow to create. + :param realm: Realm. + :return: Representation of the new authentication flow. + """ + try: + new_flow = dict( + alias=config["alias"], + providerId=config["providerId"], + description=config["description"], + topLevel=True + ) + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(new_flow), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + flow_list = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for flow in flow_list: + if flow["alias"] == config["alias"]: + return flow + return None + except Exception as e: + self.module.fail_json(msg='Could not create empty authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def update_authentication_executions(self, flowAlias, updatedExec, realm='master'): + """ Update authentication executions + + :param flowAlias: name of the parent flow + :param updatedExec: JSON containing updated execution + :return: HTTPResponse object on success + """ + try: + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=quote(flowAlias)), + method='PUT', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(updatedExec), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to update executions %s: %s" % (updatedExec, str(e))) + + def add_authenticationConfig_to_execution(self, executionId, authenticationConfig, realm='master'): + """ Add autenticatorConfig to the execution + + :param executionId: id of execution + :param authenticationConfig: config to add to the execution + :return: HTTPResponse object on success + """ + try: + open_url( + URL_AUTHENTICATION_EXECUTION_CONFIG.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(authenticationConfig), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to add authenticationConfig %s: %s" % (executionId, str(e))) + + def create_subflow(self, subflowName, flowAlias, realm='master'): + """ Create new sublow on the flow + + :param subflowName: name of the subflow to create + :param flowAlias: name of the parent flow + :return: HTTPResponse object on success + """ + try: + newSubFlow = {} + newSubFlow["alias"] = subflowName + newSubFlow["provider"] = "registration-page-form" + newSubFlow["type"] = "basic-flow" + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS_FLOW.format( + url=self.baseurl, + realm=realm, + flowalias=quote(flowAlias)), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(newSubFlow), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to create new subflow %s: %s" % (subflowName, str(e))) + + def create_execution(self, execution, flowAlias, realm='master'): + """ Create new execution on the flow + + :param execution: name of execution to create + :param flowAlias: name of the parent flow + :return: HTTPResponse object on success + """ + try: + newExec = {} + newExec["provider"] = execution["providerId"] + newExec["requirement"] = execution["requirement"] + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( + url=self.baseurl, + realm=realm, + flowalias=quote(flowAlias)), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + data=json.dumps(newExec), + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to create new execution %s: %s" % (execution["provider"], str(e))) + + def change_execution_priority(self, executionId, diff, realm='master'): + """ Raise or lower execution priority of diff time + + :param executionId: id of execution to lower priority + :param realm: realm the client is in + :param diff: Integer number, raise of diff time if positive lower of diff time if negative + :return: HTTPResponse object on success + """ + try: + if diff > 0: + for i in range(diff): + open_url( + URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + elif diff < 0: + for i in range(-diff): + open_url( + URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY.format( + url=self.baseurl, + realm=realm, + id=executionId), + method='POST', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Unable to change execution priority %s: %s" % (executionId, str(e))) + + def get_executions_representation(self, config, realm='master'): + """ + Get a representation of the executions for an authentication flow. + :param config: Representation of the authentication flow + :param realm: Realm + :return: Representation of the executions + """ + try: + # Get executions created + executions = json.load( + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=quote(config["alias"])), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + for execution in executions: + if "authenticationConfig" in execution: + execConfigId = execution["authenticationConfig"] + execConfig = json.load( + open_url( + URL_AUTHENTICATION_CONFIG.format( + url=self.baseurl, + realm=realm, + id=execConfigId), + method='GET', + http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs)) + execution["authenticationConfig"] = execConfig + return executions + except Exception as e: + self.module.fail_json(msg='Could not get executions for authentication flow %s in realm %s: %s' + % (config["alias"], realm, str(e))) + + def get_identity_providers(self, realm='master'): + """ Fetch representations for identity providers in a realm + :param realm: realm to be queried + :return: list of representations for identity providers + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(idps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity providers for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of identity providers for realm %s: %s' + % (realm, str(e))) + + def get_identity_provider(self, alias, realm='master'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param alias: Alias of the identity provider to fetch. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(idp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + + def create_identity_provider(self, idprep, realm='master'): + """ Create an identity provider. + :param idprep: Identity provider representation of the idp to be created. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return open_url(idps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(idprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create identity provider %s in realm %s: %s' + % (idprep['alias'], realm, str(e))) + + def update_identity_provider(self, idprep, realm='master'): + """ Update an existing identity provider. + :param idprep: Identity provider representation of the idp to be updated. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=idprep['alias']) + try: + return open_url(idp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(idprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update identity provider %s in realm %s: %s' + % (idprep['alias'], realm, str(e))) + + def delete_identity_provider(self, alias, realm='master'): + """ Delete an identity provider. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return open_url(idp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_identity_provider_mappers(self, alias, realm='master'): + """ Fetch representations for identity provider mappers + :param alias: Alias of the identity provider. + :param realm: realm to be queried + :return: list of representations for identity provider mappers + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(mappers_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_identity_provider_mapper(self, mid, alias, realm='master'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param mid: Unique ID of the mapper to fetch. + :param alias: Alias of the identity provider. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return json.loads(to_native(open_url(mapper_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, + timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + + def create_identity_provider_mapper(self, mapper, alias, realm='master'): + """ Create an identity provider mapper. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be created. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return open_url(mappers_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create identity provider mapper %s for idp %s in realm %s: %s' + % (mapper['name'], alias, realm, str(e))) + + def update_identity_provider_mapper(self, mapper, alias, realm='master'): + """ Update an existing identity provider. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be updated. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mapper['id']) + try: + return open_url(mapper_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(mapper), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update mapper %s for identity provider %s in realm %s: %s' + % (mapper['id'], alias, realm, str(e))) + + def delete_identity_provider_mapper(self, mid, alias, realm='master'): + """ Delete an identity provider. + :param mid: Unique ID of the mapper to delete. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return open_url(mapper_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + + def get_components(self, filter=None, realm='master'): + """ Fetch representations for components in a realm + :param realm: realm to be queried + :param filter: search filter + :return: list of representations for components + """ + comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + comps_url += '?%s' % filter + + try: + return json.loads(to_native(open_url(comps_url, method='GET', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of components for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of components for realm %s: %s' + % (realm, str(e))) + + def get_component(self, cid, realm='master'): + """ Fetch component representation from a realm using its cid. + If the component does not exist, None is returned. + :param cid: Unique ID of the component to fetch. + :param realm: Realm in which the component resides; default 'master'. + """ + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not fetch component %s in realm %s: %s' + % (cid, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch component %s in realm %s: %s' + % (cid, realm, str(e))) + + def create_component(self, comprep, realm='master'): + """ Create an component. + :param comprep: Component representation of the component to be created. + :param realm: Realm in which this component resides, default "master". + :return: Component representation of the created component + """ + comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + try: + resp = open_url(comps_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(comprep), validate_certs=self.validate_certs) + comp_url = resp.getheader('Location') + if comp_url is None: + self.module.fail_json(msg='Could not create component in realm %s: %s' + % (realm, 'unexpected response')) + return json.loads(to_native(open_url(comp_url, method="GET", http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg='Could not create component in realm %s: %s' + % (realm, str(e))) + + def update_component(self, comprep, realm='master'): + """ Update an existing component. + :param comprep: Component representation of the component to be updated. + :param realm: Realm in which this component resides, default "master". + :return HTTPResponse object on success + """ + cid = comprep.get('id') + if cid is None: + self.module.fail_json(msg='Cannot update component without id') + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return open_url(comp_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + data=json.dumps(comprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update component %s in realm %s: %s' + % (cid, realm, str(e))) + + def delete_component(self, cid, realm='master'): + """ Delete an component. + :param cid: Unique ID of the component. + :param realm: Realm in which this component resides, default "master". + """ + comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid) + try: + return open_url(comp_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete component %s in realm %s: %s' + % (cid, realm, str(e))) diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py new file mode 100644 index 0000000..e1f0e19 --- /dev/null +++ b/plugins/modules/keycloak_client.py @@ -0,0 +1,984 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_client + +short_description: Allows administration of Keycloak clients via Keycloak API + + +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/8.0/rest-api/index.html). + Aliases are provided so camelCased versions can be used as well. + + - The Keycloak API does not always sanity check inputs e.g. you can set + SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. + If you do not specify a setting, usually a sensible default is chosen. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the client + - On C(present), the client will be created (or updated if it exists already). + - On C(absent), the client will be removed if it exists + choices: ['present', 'absent'] + default: 'present' + type: str + + realm: + description: + - The realm to create the client in. + type: str + default: master + + client_id: + description: + - Client id of client to be worked on. This is usually an alphanumeric name chosen by + you. Either this or I(id) is required. If you specify both, I(id) takes precedence. + This is 'clientId' in the Keycloak REST API. + aliases: + - clientId + type: str + + id: + description: + - Id of client to be worked on. This is usually an UUID. Either this or I(client_id) + is required. If you specify both, this takes precedence. + type: str + + name: + description: + - Name of the client (this is not the same as I(client_id)). + type: str + + description: + description: + - Description of the client in Keycloak. + type: str + + root_url: + description: + - Root URL appended to relative URLs for this client. + This is 'rootUrl' in the Keycloak REST API. + aliases: + - rootUrl + type: str + + admin_url: + description: + - URL to the admin interface of the client. + This is 'adminUrl' in the Keycloak REST API. + aliases: + - adminUrl + type: str + + base_url: + description: + - Default URL to use when the auth server needs to redirect or link back to the client + This is 'baseUrl' in the Keycloak REST API. + aliases: + - baseUrl + type: str + + enabled: + description: + - Is this client enabled or not? + type: bool + + client_authenticator_type: + description: + - How do clients authenticate with the auth server? Either C(client-secret) or + C(client-jwt) can be chosen. When using C(client-secret), the module parameter + I(secret) can set it, while for C(client-jwt), you can use the keys C(use.jwks.url), + C(jwks.url), and C(jwt.credential.certificate) in the I(attributes) module parameter + to configure its behavior. + This is 'clientAuthenticatorType' in the Keycloak REST API. + choices: ['client-secret', 'client-jwt'] + aliases: + - clientAuthenticatorType + type: str + + secret: + description: + - When using I(client_authenticator_type) C(client-secret) (the default), you can + specify a secret here (otherwise one will be generated if it does not exit). If + changing this secret, the module will not register a change currently (but the + changed secret will be saved). + type: str + + registration_access_token: + description: + - The registration access token provides access for clients to the client registration + service. + This is 'registrationAccessToken' in the Keycloak REST API. + aliases: + - registrationAccessToken + type: str + + default_roles: + description: + - list of default roles for this client. If the client roles referenced do not exist + yet, they will be created. + This is 'defaultRoles' in the Keycloak REST API. + aliases: + - defaultRoles + type: list + elements: str + + redirect_uris: + description: + - Acceptable redirect URIs for this client. + This is 'redirectUris' in the Keycloak REST API. + aliases: + - redirectUris + type: list + elements: str + + web_origins: + description: + - List of allowed CORS origins. + This is 'webOrigins' in the Keycloak REST API. + aliases: + - webOrigins + type: list + elements: str + + not_before: + description: + - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). + This is 'notBefore' in the Keycloak REST API. + type: int + aliases: + - notBefore + + bearer_only: + description: + - The access type of this client is bearer-only. + This is 'bearerOnly' in the Keycloak REST API. + aliases: + - bearerOnly + type: bool + + consent_required: + description: + - If enabled, users have to consent to client access. + This is 'consentRequired' in the Keycloak REST API. + aliases: + - consentRequired + type: bool + + standard_flow_enabled: + description: + - Enable standard flow for this client or not (OpenID connect). + This is 'standardFlowEnabled' in the Keycloak REST API. + aliases: + - standardFlowEnabled + type: bool + + implicit_flow_enabled: + description: + - Enable implicit flow for this client or not (OpenID connect). + This is 'implicitFlowEnabled' in the Keycloak REST API. + aliases: + - implicitFlowEnabled + type: bool + + direct_access_grants_enabled: + description: + - Are direct access grants enabled for this client or not (OpenID connect). + This is 'directAccessGrantsEnabled' in the Keycloak REST API. + aliases: + - directAccessGrantsEnabled + type: bool + + service_accounts_enabled: + description: + - Are service accounts enabled for this client or not (OpenID connect). + This is 'serviceAccountsEnabled' in the Keycloak REST API. + aliases: + - serviceAccountsEnabled + type: bool + + authorization_services_enabled: + description: + - Are authorization services enabled for this client or not (OpenID connect). + This is 'authorizationServicesEnabled' in the Keycloak REST API. + aliases: + - authorizationServicesEnabled + type: bool + + public_client: + description: + - Is the access type for this client public or not. + This is 'publicClient' in the Keycloak REST API. + aliases: + - publicClient + type: bool + + frontchannel_logout: + description: + - Is frontchannel logout enabled for this client or not. + This is 'frontchannelLogout' in the Keycloak REST API. + aliases: + - frontchannelLogout + type: bool + + protocol: + description: + - Type of client (either C(openid-connect) or C(saml). + type: str + choices: ['openid-connect', 'saml'] + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client or not. + This is 'fullScopeAllowed' in the Keycloak REST API. + aliases: + - fullScopeAllowed + type: bool + + node_re_registration_timeout: + description: + - Cluster node re-registration timeout for this client. + This is 'nodeReRegistrationTimeout' in the Keycloak REST API. + type: int + aliases: + - nodeReRegistrationTimeout + + registered_nodes: + description: + - dict of registered cluster nodes (with C(nodename) as the key and last registration + time as the value). + This is 'registeredNodes' in the Keycloak REST API. + type: dict + aliases: + - registeredNodes + + client_template: + description: + - Client template to use for this client. If it does not exist this field will silently + be dropped. + This is 'clientTemplate' in the Keycloak REST API. + type: str + aliases: + - clientTemplate + + use_template_config: + description: + - Whether or not to use configuration from the I(client_template). + This is 'useTemplateConfig' in the Keycloak REST API. + aliases: + - useTemplateConfig + type: bool + + use_template_scope: + description: + - Whether or not to use scope configuration from the I(client_template). + This is 'useTemplateScope' in the Keycloak REST API. + aliases: + - useTemplateScope + type: bool + + use_template_mappers: + description: + - Whether or not to use mapper configuration from the I(client_template). + This is 'useTemplateMappers' in the Keycloak REST API. + aliases: + - useTemplateMappers + type: bool + + always_display_in_console: + description: + - Whether or not to display this client in account console, even if the + user does not have an active session. + aliases: + - alwaysDisplayInConsole + type: bool + version_added: 4.7.0 + + surrogate_auth_required: + description: + - Whether or not surrogate auth is required. + This is 'surrogateAuthRequired' in the Keycloak REST API. + aliases: + - surrogateAuthRequired + type: bool + + authorization_settings: + description: + - a data structure defining the authorization settings for this client. For reference, + please see the Keycloak API docs at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html#_resourceserverrepresentation). + This is 'authorizationSettings' in the Keycloak REST API. + type: dict + aliases: + - authorizationSettings + + authentication_flow_binding_overrides: + description: + - Override realm authentication flow bindings. + type: dict + aliases: + - authenticationFlowBindingOverrides + version_added: 3.4.0 + + default_client_scopes: + description: + - List of default client scopes. + aliases: + - defaultClientScopes + type: list + elements: str + version_added: 4.7.0 + + optional_client_scopes: + description: + - List of optional client scopes. + aliases: + - optionalClientScopes + type: list + elements: str + version_added: 4.7.0 + + protocol_mappers: + description: + - a list of dicts defining protocol mappers for this client. + This is 'protocolMappers' in the Keycloak REST API. + aliases: + - protocolMappers + type: list + elements: dict + suboptions: + consentRequired: + description: + - Specifies whether a user needs to provide consent to a client for this mapper to be active. + type: bool + + consentText: + description: + - The human-readable name of the consent the user is presented to accept. + type: str + + id: + description: + - Usually a UUID specifying the internal ID of this protocol mapper instance. + type: str + + name: + description: + - The name of this protocol mapper. + type: str + + protocol: + description: + - This is either C(openid-connect) or C(saml), this specifies for which protocol this protocol mapper. + is active. + choices: ['openid-connect', 'saml'] + type: str + + protocolMapper: + description: + - The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is + impossible to provide since this may be extended through SPIs by the user of Keycloak, + by default Keycloak as of 3.4 ships with at least + - C(docker-v2-allow-all-mapper) + - C(oidc-address-mapper) + - C(oidc-full-name-mapper) + - C(oidc-group-membership-mapper) + - C(oidc-hardcoded-claim-mapper) + - C(oidc-hardcoded-role-mapper) + - C(oidc-role-name-mapper) + - C(oidc-script-based-protocol-mapper) + - C(oidc-sha256-pairwise-sub-mapper) + - C(oidc-usermodel-attribute-mapper) + - C(oidc-usermodel-client-role-mapper) + - C(oidc-usermodel-property-mapper) + - C(oidc-usermodel-realm-role-mapper) + - C(oidc-usersessionmodel-note-mapper) + - C(saml-group-membership-mapper) + - C(saml-hardcode-attribute-mapper) + - C(saml-hardcode-role-mapper) + - C(saml-role-list-mapper) + - C(saml-role-name-mapper) + - C(saml-user-attribute-mapper) + - C(saml-user-property-mapper) + - C(saml-user-session-note-mapper) + - An exhaustive list of available mappers on your installation can be obtained on + the admin console by going to Server Info -> Providers and looking under + 'protocol-mapper'. + type: str + + config: + description: + - Dict specifying the configuration options for the protocol mapper; the + contents differ depending on the value of I(protocolMapper) and are not documented + other than by the source of the mappers and its parent class(es). An example is given + below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the I(existing) field. + type: dict + + attributes: + description: + - A dict of further attributes for this client. This can contain various configuration + settings; an example is given in the examples section. While an exhaustive list of + permissible options is not available; possible options as of Keycloak 3.4 are listed below. The Keycloak + API does not validate whether a given option is appropriate for the protocol used; if specified + anyway, Keycloak will simply not use it. + type: dict + suboptions: + saml.authnstatement: + description: + - For SAML clients, boolean specifying whether or not a statement containing method and timestamp + should be included in the login response. + + saml.client.signature: + description: + - For SAML clients, boolean specifying whether a client signature is required and validated. + + saml.encrypt: + description: + - Boolean specifying whether SAML assertions should be encrypted with the client's public key. + + saml.force.post.binding: + description: + - For SAML clients, boolean specifying whether always to use POST binding for responses. + + saml.onetimeuse.condition: + description: + - For SAML clients, boolean specifying whether a OneTimeUse condition should be included in login responses. + + saml.server.signature: + description: + - Boolean specifying whether SAML documents should be signed by the realm. + + saml.server.signature.keyinfo.ext: + description: + - For SAML clients, boolean specifying whether REDIRECT signing key lookup should be optimized through inclusion + of the signing key id in the SAML Extensions element. + + saml.signature.algorithm: + description: + - Signature algorithm used to sign SAML documents. One of C(RSA_SHA256), C(RSA_SHA1), C(RSA_SHA512), or C(DSA_SHA1). + + saml.signing.certificate: + description: + - SAML signing key certificate, base64-encoded. + + saml.signing.private.key: + description: + - SAML signing key private key, base64-encoded. + + saml_assertion_consumer_url_post: + description: + - SAML POST Binding URL for the client's assertion consumer service (login responses). + + saml_assertion_consumer_url_redirect: + description: + - SAML Redirect Binding URL for the client's assertion consumer service (login responses). + + + saml_force_name_id_format: + description: + - For SAML clients, Boolean specifying whether to ignore requested NameID subject format and using the configured one instead. + + saml_name_id_format: + description: + - For SAML clients, the NameID format to use (one of C(username), C(email), C(transient), or C(persistent)) + + saml_signature_canonicalization_method: + description: + - SAML signature canonicalization method. This is one of four values, namely + C(http://www.w3.org/2001/10/xml-exc-c14n#) for EXCLUSIVE, + C(http://www.w3.org/2001/10/xml-exc-c14n#WithComments) for EXCLUSIVE_WITH_COMMENTS, + C(http://www.w3.org/TR/2001/REC-xml-c14n-20010315) for INCLUSIVE, and + C(http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments) for INCLUSIVE_WITH_COMMENTS. + + saml_single_logout_service_url_post: + description: + - SAML POST binding url for the client's single logout service. + + saml_single_logout_service_url_redirect: + description: + - SAML redirect binding url for the client's single logout service. + + user.info.response.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm for signed UserInfo-endpoint responses. One of C(RS256) or C(unsigned). + + request.object.signature.alg: + description: + - For OpenID-Connect clients, JWA algorithm which the client needs to use when sending + OIDC request object. One of C(any), C(none), C(RS256). + + use.jwks.url: + description: + - For OpenID-Connect clients, boolean specifying whether to use a JWKS URL to obtain client + public keys. + + jwks.url: + description: + - For OpenID-Connect clients, URL where client keys in JWK are stored. + + jwt.credential.certificate: + description: + - For OpenID-Connect clients, client certificate for validating JWT issued by + client and signed by its key, base64-encoded. + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Create or update Keycloak client (minimal example), authentication with credentials + middleware_automation.keycloak.keycloak_client: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: present + delegate_to: localhost + + +- name: Create or update Keycloak client (minimal example), authentication with token + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + token: TOKEN + client_id: test + state: present + delegate_to: localhost + + +- name: Delete a Keycloak client + middleware_automation.keycloak.keycloak_client: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: absent + delegate_to: localhost + + +- name: Create or update a Keycloak client (with all the bells and whistles) + middleware_automation.keycloak.keycloak_client: + 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: master + client_id: test + id: d8b127a3-31f6-44c8-a7e4-4ab9a3e78d95 + name: this_is_a_test + description: Description of this wonderful client + root_url: https://www.example.com/ + admin_url: https://www.example.com/admin_url + base_url: basepath + enabled: true + client_authenticator_type: client-secret + secret: REALLYWELLKEPTSECRET + redirect_uris: + - https://www.example.com/* + - http://localhost:8888/ + web_origins: + - https://www.example.com/* + not_before: 1507825725 + bearer_only: false + consent_required: false + standard_flow_enabled: true + implicit_flow_enabled: false + direct_access_grants_enabled: false + service_accounts_enabled: false + authorization_services_enabled: false + public_client: false + frontchannel_logout: false + protocol: openid-connect + full_scope_allowed: false + node_re_registration_timeout: -1 + client_template: test + use_template_config: false + use_template_scope: false + use_template_mappers: false + always_display_in_console: true + registered_nodes: + node01.example.com: 1507828202 + registration_access_token: eyJWT_TOKEN + surrogate_auth_required: false + default_roles: + - test01 + - test02 + authentication_flow_binding_overrides: + browser: 4c90336b-bf1d-4b87-916d-3677ba4e5fbb + protocol_mappers: + - config: + access.token.claim: true + claim.name: "family_name" + id.token.claim: true + jsonType.label: String + user.attribute: lastName + userinfo.token.claim: true + consentRequired: true + consentText: "${familyName}" + name: family name + protocol: openid-connect + protocolMapper: oidc-usermodel-property-mapper + - config: + attribute.name: Role + attribute.nameformat: Basic + single: false + consentRequired: false + name: role list + protocol: saml + protocolMapper: saml-role-list-mapper + attributes: + saml.authnstatement: true + saml.client.signature: true + saml.force.post.binding: true + saml.server.signature: true + saml.signature.algorithm: RSA_SHA256 + saml.signing.certificate: CERTIFICATEHERE + saml.signing.private.key: PRIVATEKEYHERE + saml_force_name_id_format: false + saml_name_id_format: username + saml_signature_canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#" + user.info.response.signature.alg: RS256 + request.object.signature.alg: RS256 + use.jwks.url: true + jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT + jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Client testclient has been updated" + +proposed: + description: Representation of proposed client. + returned: always + type: dict + sample: { + clientId: "test" + } + +existing: + description: Representation of existing client (sample is truncated). + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } + +end_state: + description: Representation of client after module execution (sample is truncated). + returned: on success + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +import copy + + +def normalise_cr(clientrep, remove_ids=False): + """ Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the + the change detection is more effective. + + :param clientrep: the clientrep dict to be sanitized + :param remove_ids: If set to true, then the unique ID's of objects is removed to make the diff and checks for changed + not alert when the ID's of objects are not usually known, (e.g. for protocol_mappers) + :return: normalised clientrep dict + """ + # Avoid the dict passed in to be modified + clientrep = clientrep.copy() + + if 'attributes' in clientrep: + clientrep['attributes'] = list(sorted(clientrep['attributes'])) + + if 'redirectUris' in clientrep: + clientrep['redirectUris'] = list(sorted(clientrep['redirectUris'])) + + if 'protocolMappers' in clientrep: + clientrep['protocolMappers'] = sorted(clientrep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper'))) + for mapper in clientrep['protocolMappers']: + if remove_ids: + mapper.pop('id', None) + + # Set to a default value. + mapper['consentRequired'] = mapper.get('consentRequired', False) + + return clientrep + + +def sanitize_cr(clientrep): + """ Removes probably sensitive details from a client representation. + + :param clientrep: the clientrep dict to be sanitized + :return: sanitized clientrep dict + """ + result = copy.deepcopy(clientrep) + if 'secret' in result: + result['secret'] = 'no_log' + if 'attributes' in result: + if 'saml.signing.private.key' in result['attributes']: + result['attributes']['saml.signing.private.key'] = 'no_log' + return normalise_cr(result) + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + protmapper_spec = dict( + consentRequired=dict(type='bool'), + consentText=dict(type='str'), + id=dict(type='str'), + name=dict(type='str'), + protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocolMapper=dict(type='str'), + config=dict(type='dict'), + ) + + 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'), + description=dict(type='str'), + root_url=dict(type='str', aliases=['rootUrl']), + admin_url=dict(type='str', aliases=['adminUrl']), + base_url=dict(type='str', aliases=['baseUrl']), + surrogate_auth_required=dict(type='bool', aliases=['surrogateAuthRequired']), + enabled=dict(type='bool'), + client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt'], aliases=['clientAuthenticatorType']), + secret=dict(type='str', no_log=True), + registration_access_token=dict(type='str', aliases=['registrationAccessToken'], no_log=True), + default_roles=dict(type='list', elements='str', aliases=['defaultRoles']), + redirect_uris=dict(type='list', elements='str', aliases=['redirectUris']), + web_origins=dict(type='list', elements='str', aliases=['webOrigins']), + not_before=dict(type='int', aliases=['notBefore']), + bearer_only=dict(type='bool', aliases=['bearerOnly']), + consent_required=dict(type='bool', aliases=['consentRequired']), + standard_flow_enabled=dict(type='bool', aliases=['standardFlowEnabled']), + implicit_flow_enabled=dict(type='bool', aliases=['implicitFlowEnabled']), + direct_access_grants_enabled=dict(type='bool', aliases=['directAccessGrantsEnabled']), + service_accounts_enabled=dict(type='bool', aliases=['serviceAccountsEnabled']), + authorization_services_enabled=dict(type='bool', aliases=['authorizationServicesEnabled']), + public_client=dict(type='bool', aliases=['publicClient']), + frontchannel_logout=dict(type='bool', aliases=['frontchannelLogout']), + protocol=dict(type='str', choices=['openid-connect', 'saml']), + attributes=dict(type='dict'), + full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), + node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), + registered_nodes=dict(type='dict', aliases=['registeredNodes']), + client_template=dict(type='str', aliases=['clientTemplate']), + use_template_config=dict(type='bool', aliases=['useTemplateConfig']), + use_template_scope=dict(type='bool', aliases=['useTemplateScope']), + use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), + always_display_in_console=dict(type='bool', aliases=['alwaysDisplayInConsole']), + authentication_flow_binding_overrides=dict(type='dict', aliases=['authenticationFlowBindingOverrides']), + protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), + authorization_settings=dict(type='dict', aliases=['authorizationSettings']), + default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']), + optional_client_scopes=dict(type='list', elements='str', aliases=['optionalClientScopes']), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['client_id', 'id'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + cid = module.params.get('id') + state = module.params.get('state') + + # Filter and map the parameters names that apply to the client + client_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] + + # See if it already exists in Keycloak + if cid is None: + before_client = kc.get_client_by_clientid(module.params.get('client_id'), realm=realm) + if before_client is not None: + cid = before_client['id'] + else: + before_client = kc.get_client_by_id(cid, realm=realm) + + if before_client is None: + before_client = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for client_param in client_params: + new_param_value = module.params.get(client_param) + + # some lists in the Keycloak API are sorted, some are not. + if isinstance(new_param_value, list): + if client_param in ['attributes']: + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + # Unfortunately, the ansible argument spec checker introduces variables with null values when + # they are not specified + if client_param == 'protocol_mappers': + new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + + changeset[camel(client_param)] = new_param_value + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_client = before_client.copy() + desired_client.update(changeset) + + result['proposed'] = sanitize_cr(changeset) + result['existing'] = sanitize_cr(before_client) + + # Cater for when it doesn't exist (an empty dict) + if not before_client: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Client does not exist; doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if 'clientId' not in desired_client: + module.fail_json(msg='client_id needs to be specified when creating a new client') + + if module._diff: + result['diff'] = dict(before='', after=sanitize_cr(desired_client)) + + if module.check_mode: + module.exit_json(**result) + + # create it + kc.create_client(desired_client, realm=realm) + after_client = kc.get_client_by_clientid(desired_client['clientId'], realm=realm) + + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been created.' % desired_client['clientId'] + module.exit_json(**result) + + else: + if state == 'present': + # Process an update + result['changed'] = True + + if module.check_mode: + # We can only compare the current client with the proposed updates we have + before_norm = normalise_cr(before_client, remove_ids=True) + desired_norm = normalise_cr(desired_client, remove_ids=True) + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_norm), + after=sanitize_cr(desired_norm)) + result['changed'] = (before_norm != desired_norm) + + module.exit_json(**result) + + # do the update + kc.update_client(cid, desired_client, realm=realm) + + after_client = kc.get_client_by_id(cid, realm=realm) + if before_client == after_client: + result['changed'] = False + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), + after=sanitize_cr(after_client)) + + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been updated.' % desired_client['clientId'] + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_client(cid, realm=realm) + result['proposed'] = {} + + result['end_state'] = {} + + result['msg'] = 'Client %s has been deleted.' % before_client['clientId'] + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_role.py b/plugins/modules/keycloak_role.py new file mode 100644 index 0000000..0045d0e --- /dev/null +++ b/plugins/modules/keycloak_role.py @@ -0,0 +1,374 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Adam Goossens +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_role + +short_description: Allows administration of Keycloak roles via Keycloak API + +version_added: 3.4.0 + +description: + - This module allows you to add, remove or modify Keycloak roles via the Keycloak REST API. + It requires access to the REST API via OpenID Connect; the user connecting and the client being + used must have the requisite access rights. In a default Keycloak installation, admin-cli + and an admin user would work, as would a separate client definition with the scope tailored + to your needs and a user having the expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/8.0/rest-api/index.html). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the role. + - On C(present), the role will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the role will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + required: true + description: + - Name of the role. + - This parameter is required. + + description: + type: str + description: + - The role description. + + realm: + type: str + description: + - The Keycloak realm under which this role resides. + default: 'master' + + client_id: + type: str + description: + - If the role is a client role, the client id under which it resides. + - If this parameter is absent, the role is considered a realm role. + + attributes: + type: dict + description: + - A dict of key/value pairs to set as custom attributes for the role. + - Values may be single values (e.g. a string) or a list of strings. + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +''' + +EXAMPLES = ''' +- name: Create a Keycloak realm role, authentication with credentials + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a Keycloak realm role, authentication with token + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost + +- name: Create a Keycloak client role + middleware_automation.keycloak.keycloak_role: + name: my-new-kc-role + realm: MyCustomRealm + client_id: MyClient + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Delete a Keycloak role + middleware_automation.keycloak.keycloak_role: + name: my-role-for-deletion + state: absent + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a keycloak role with some custom attributes + middleware_automation.keycloak.keycloak_role: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new-role + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role myrole has been updated" + +proposed: + description: Representation of proposed role. + returned: always + type: dict + sample: { + "description": "My updated test description" + } + +existing: + description: Representation of existing role. + returned: always + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } + +end_state: + description: Representation of role after module execution (sample is truncated). + returned: on success + type: dict + sample: { + "attributes": {}, + "clientRole": true, + "composite": false, + "containerId": "9f03eb61-a826-4771-a9fd-930e06d2d36a", + "description": "My updated client test role", + "id": "561703dd-0f38-45ff-9a5a-0c978f794547", + "name": "myrole" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + name=dict(type='str', required=True), + description=dict(type='str'), + realm=dict(type='str', default='master'), + client_id=dict(type='str'), + attributes=dict(type='dict'), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + clientid = module.params.get('client_id') + name = module.params.get('name') + state = module.params.get('state') + + # attributes in Keycloak have their values returned as lists + # via the API. attributes is a dict, so we'll transparently convert + # the values to lists. + if module.params.get('attributes') is not None: + for key, val in module.params['attributes'].items(): + module.params['attributes'][key] = [val] if not isinstance(val, list) else val + + # Filter and map the parameters names that apply to the role + role_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id', 'composites'] and + module.params.get(x) is not None] + + # See if it already exists in Keycloak + if clientid is None: + before_role = kc.get_realm_role(name, realm) + else: + before_role = kc.get_client_role(name, clientid, realm) + + if before_role is None: + before_role = {} + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in role_params: + new_param_value = module.params.get(param) + old_value = before_role[param] if param in before_role else None + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_role = before_role.copy() + desired_role.update(changeset) + + result['proposed'] = changeset + result['existing'] = before_role + + # Cater for when it doesn't exist (an empty dict) + if not before_role: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'Role does not exist, doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if name is None: + module.fail_json(msg='name must be specified when creating a new role') + + if module._diff: + result['diff'] = dict(before='', after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # create it + if clientid is None: + kc.create_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.create_client_role(desired_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + + result['end_state'] = after_role + + result['msg'] = 'Role {name} has been created'.format(name=name) + module.exit_json(**result) + + else: + if state == 'present': + # Process an update + + # no changes + if desired_role == before_role: + result['changed'] = False + result['end_state'] = desired_role + result['msg'] = "No changes required to role {name}.".format(name=name) + module.exit_json(**result) + + # doing an update + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after=desired_role) + + if module.check_mode: + module.exit_json(**result) + + # do the update + if clientid is None: + kc.update_realm_role(desired_role, realm) + after_role = kc.get_realm_role(name, realm) + else: + kc.update_client_role(desired_role, clientid, realm) + after_role = kc.get_client_role(name, clientid, realm) + + result['end_state'] = after_role + + result['msg'] = "Role {name} has been updated".format(name=name) + module.exit_json(**result) + + else: + # Process a deletion (because state was not 'present') + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_role, after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + if clientid is None: + kc.delete_realm_role(name, realm) + else: + kc.delete_client_role(name, clientid, realm) + + result['end_state'] = {} + + result['msg'] = "Role {name} has been deleted".format(name=name) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py new file mode 100644 index 0000000..96f04d7 --- /dev/null +++ b/plugins/modules/keycloak_user_federation.py @@ -0,0 +1,1021 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) Ansible project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_user_federation + +short_description: Allows administration of Keycloak user federations via Keycloak API + +version_added: 3.7.0 + +description: + - This module allows you to add, remove or modify Keycloak user federations 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/20.0.2/rest-api/index.html). + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the user federation. + - On C(present), the user federation will be created if it does not yet exist, or updated with + the parameters you provide. + - On C(absent), the user federation will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + description: + - The Keycloak realm under which this user federation resides. + default: 'master' + type: str + + id: + description: + - The unique ID for this user federation. If left empty, the user federation will be searched + by its I(name). + type: str + + name: + description: + - Display name of provider when linked in admin console. + type: str + + provider_id: + description: + - Provider for this user federation. + aliases: + - providerId + type: str + choices: + - ldap + - kerberos + - sssd + + provider_type: + description: + - Component type for user federation (only supported value is C(org.keycloak.storage.UserStorageProvider)). + aliases: + - providerType + default: org.keycloak.storage.UserStorageProvider + type: str + + parent_id: + description: + - Unique ID for the parent of this user federation. Realm ID will be automatically used if left blank. + aliases: + - parentId + type: str + + config: + description: + - Dict specifying the configuration options for the provider; the contents differ depending on + the value of I(provider_id). Examples are given below for C(ldap), C(kerberos) and C(sssd). + It is easiest to obtain valid config values by dumping an already-existing user federation + configuration through check-mode in the I(existing) field. + - The value C(sssd) has been supported since middleware_automation.keycloak 1.0.0. + type: dict + suboptions: + enabled: + description: + - Enable/disable this user federation. + default: true + type: bool + + priority: + description: + - Priority of provider when doing a user lookup. Lowest first. + default: 0 + type: int + + importEnabled: + description: + - If C(true), LDAP users will be imported into Keycloak DB and synced by the configured + sync policies. + default: true + type: bool + + editMode: + description: + - C(READ_ONLY) is a read-only LDAP store. C(WRITABLE) means data will be synced back to LDAP + on demand. C(UNSYNCED) means user data will be imported, but not synced back to LDAP. + type: str + choices: + - READ_ONLY + - WRITABLE + - UNSYNCED + + syncRegistrations: + description: + - Should newly created users be created within LDAP store? Priority effects which + provider is chosen to sync the new user. + default: false + type: bool + + vendor: + description: + - LDAP vendor (provider). + - Use short name. For instance, write C(rhds) for "Red Hat Directory Server". + type: str + + usernameLDAPAttribute: + description: + - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server + vendors it can be C(uid). For Active directory it can be C(sAMAccountName) or C(cn). + The attribute should be filled for all LDAP user records you want to import from + LDAP to Keycloak. + type: str + + rdnLDAPAttribute: + description: + - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN. + Usually it's the same as Username LDAP attribute, however it is not required. For + example for Active directory, it is common to use C(cn) as RDN attribute when + username attribute might be C(sAMAccountName). + type: str + + uuidLDAPAttribute: + description: + - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects + in LDAP. For many LDAP server vendors, it is C(entryUUID); however some are different. + For example for Active directory it should be C(objectGUID). If your LDAP server does + not support the notion of UUID, you can use any other attribute that is supposed to + be unique among LDAP users in tree. + type: str + + userObjectClasses: + description: + - All values of LDAP objectClass attribute for users in LDAP divided by comma. + For example C(inetOrgPerson, organizationalPerson). Newly created Keycloak users + will be written to LDAP with all those object classes and existing LDAP user records + are found just if they contain all those object classes. + type: str + + connectionUrl: + description: + - Connection URL to your LDAP server. + type: str + + usersDn: + description: + - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users. + type: str + + customUserSearchFilter: + description: + - Additional LDAP Filter for filtering searched users. Leave this empty if you don't + need additional filter. + type: str + + searchScope: + description: + - For one level, the search applies only for users in the DNs specified by User DNs. + For subtree, the search applies to the whole subtree. See LDAP documentation for + more details. + default: '1' + type: str + choices: + - '1' + - '2' + + authType: + description: + - Type of the Authentication method used during LDAP Bind operation. It is used in + most of the requests sent to the LDAP server. + default: 'none' + type: str + choices: + - none + - simple + + bindDn: + description: + - DN of LDAP user which will be used by Keycloak to access LDAP server. + type: str + + bindCredential: + description: + - Password of LDAP admin. + type: str + + startTls: + description: + - Encrypts the connection to LDAP using STARTTLS, which will disable connection pooling. + default: false + type: bool + + usePasswordModifyExtendedOp: + description: + - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify + extended operation usually requires that LDAP user already has password in the LDAP + server. So when this is used with 'Sync Registrations', it can be good to add also + 'Hardcoded LDAP attribute mapper' with randomly generated initial password. + default: false + type: bool + + validatePasswordPolicy: + description: + - Determines if Keycloak should validate the password with the realm password policy + before updating it. + default: false + type: bool + + trustEmail: + description: + - If enabled, email provided by this provider is not verified even if verification is + enabled for the realm. + default: false + type: bool + + useTruststoreSpi: + description: + - Specifies whether LDAP connection will use the truststore SPI with the truststore + configured in standalone.xml/domain.xml. C(Always) means that it will always use it. + C(Never) means that it will not use it. C(Only for ldaps) means that it will use if + your connection URL use ldaps. Note even if standalone.xml/domain.xml is not + configured, the default Java cacerts or certificate specified by + C(javax.net.ssl.trustStore) property will be used. + default: ldapsOnly + type: str + choices: + - always + - ldapsOnly + - never + + connectionTimeout: + description: + - LDAP Connection Timeout in milliseconds. + type: int + + readTimeout: + description: + - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations. + type: int + + pagination: + description: + - Does the LDAP server support pagination. + default: true + type: bool + + connectionPooling: + description: + - Determines if Keycloak should use connection pooling for accessing LDAP server. + default: true + type: bool + + connectionPoolingAuthentication: + description: + - A list of space-separated authentication types of connections that may be pooled. + type: str + choices: + - none + - simple + - DIGEST-MD5 + + connectionPoolingDebug: + description: + - A string that indicates the level of debug output to produce. Example valid values are + C(fine) (trace connection creation and removal) and C(all) (all debugging information). + type: str + + connectionPoolingInitSize: + description: + - The number of connections per connection identity to create when initially creating a + connection for the identity. + type: int + + connectionPoolingMaxSize: + description: + - The maximum number of connections per connection identity that can be maintained + concurrently. + type: int + + connectionPoolingPrefSize: + description: + - The preferred number of connections per connection identity that should be maintained + concurrently. + type: int + + connectionPoolingProtocol: + description: + - A list of space-separated protocol types of connections that may be pooled. + Valid types are C(plain) and C(ssl). + type: str + + connectionPoolingTimeout: + description: + - The number of milliseconds that an idle connection may remain in the pool without + being closed and removed from the pool. + type: int + + allowKerberosAuthentication: + description: + - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data + about authenticated users will be provisioned from this LDAP server. + default: false + type: bool + + kerberosRealm: + description: + - Name of kerberos realm. + type: str + + serverPrincipal: + description: + - Full name of server principal for HTTP service including server and domain name. For + example C(HTTP/host.foo.org@FOO.ORG). Use C(*) to accept any service principal in the + KeyTab file. + type: str + + keyTab: + description: + - Location of Kerberos KeyTab file containing the credentials of server principal. For + example C(/etc/krb5.keytab). + type: str + + debug: + description: + - Enable/disable debug logging to standard output for Krb5LoginModule. + type: bool + + useKerberosForPasswordAuthentication: + description: + - Use Kerberos login module for authenticate username/password against Kerberos server + instead of authenticating against LDAP server with Directory Service API. + default: false + type: bool + + allowPasswordAuthentication: + description: + - Enable/disable possibility of username/password authentication against Kerberos database. + type: bool + + batchSizeForSync: + description: + - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction. + default: 1000 + type: int + + fullSyncPeriod: + description: + - Period for full synchronization in seconds. + default: -1 + type: int + + changedSyncPeriod: + description: + - Period for synchronization of changed or newly created LDAP users in seconds. + default: -1 + type: int + + updateProfileFirstLogin: + description: + - Update profile on first login. + type: bool + + cachePolicy: + description: + - Cache Policy for this storage provider. + type: str + default: 'DEFAULT' + choices: + - DEFAULT + - EVICT_DAILY + - EVICT_WEEKLY + - MAX_LIFESPAN + - NO_CACHE + + evictionDay: + description: + - Day of the week the entry will become invalid on. + type: str + + evictionHour: + description: + - Hour of day the entry will become invalid on. + type: str + + evictionMinute: + description: + - Minute of day the entry will become invalid on. + type: str + + maxLifespan: + description: + - Max lifespan of cache entry in milliseconds. + type: int + + mappers: + description: + - A list of dicts defining mappers associated with this Identity Provider. + type: list + elements: dict + suboptions: + id: + description: + - Unique ID of this mapper. + type: str + + name: + description: + - Name of the mapper. If no ID is given, the mapper will be searched by name. + type: str + + parentId: + description: + - Unique ID for the parent of this mapper. ID of the user federation will automatically + be used if left blank. + type: str + + providerId: + description: + - The mapper type for this mapper (for instance C(user-attribute-ldap-mapper)). + type: str + + providerType: + description: + - Component type for this mapper. + type: str + default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper + + config: + description: + - Dict specifying the configuration options for the mapper; the contents differ + depending on the value of I(identityProviderMapper). + type: dict + +extends_documentation_fragment: + - middleware_automation.keycloak.keycloak + - middleware_automation.keycloak.attributes + +author: + - Laurent Paumier (@laurpaum) +''' + +EXAMPLES = ''' + - name: Create LDAP user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-ldap + state: present + provider_id: ldap + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + batchSizeForSync: 1000 + editMode: READ_ONLY + importEnabled: true + syncRegistrations: false + vendor: other + usernameLDAPAttribute: uid + rdnLDAPAttribute: uid + uuidLDAPAttribute: entryUUID + userObjectClasses: inetOrgPerson, organizationalPerson + connectionUrl: ldaps://ldap.example.com:636 + usersDn: ou=Users,dc=example,dc=com + authType: simple + bindDn: cn=directory reader + bindCredential: password + searchScope: 1 + validatePasswordPolicy: false + trustEmail: false + useTruststoreSpi: ldapsOnly + connectionPooling: true + pagination: true + allowKerberosAuthentication: false + debug: false + useKerberosForPasswordAuthentication: false + mappers: + - name: "full name" + providerId: "full-name-ldap-mapper" + providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + config: + ldap.full.name.attribute: cn + read.only: true + write.only: false + + - name: Create Kerberos user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-kerberos + state: present + provider_id: kerberos + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + kerberosRealm: EXAMPLE.COM + serverPrincipal: HTTP/host.example.com@EXAMPLE.COM + keyTab: keytab + allowPasswordAuthentication: false + updateProfileFirstLogin: false + + - name: Create sssd user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-sssd + state: present + provider_id: sssd + provider_type: org.keycloak.storage.UserStorageProvider + config: + priority: 0 + enabled: true + cachePolicy: DEFAULT + + - name: Delete user federation + middleware_automation.keycloak.keycloak_user_federation: + auth_keycloak_url: https://keycloak.example.com/auth + auth_realm: master + auth_username: admin + auth_password: password + realm: my-realm + name: my-federation + state: absent + +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799." + +proposed: + description: Representation of proposed user federation. + returned: always + type: dict + sample: { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "name": "ldap", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" + } + +existing: + description: Representation of existing user federation. + returned: always + type: dict + sample: { + "config": { + "allowKerberosAuthentication": "false", + "authType": "simple", + "batchSizeForSync": "1000", + "bindCredential": "**********", + "bindDn": "cn=directory reader", + "cachePolicy": "DEFAULT", + "changedSyncPeriod": "-1", + "connectionPooling": "true", + "connectionUrl": "ldaps://ldap.example.com:636", + "debug": "false", + "editMode": "READ_ONLY", + "enabled": "true", + "fullSyncPeriod": "-1", + "importEnabled": "true", + "pagination": "true", + "priority": "0", + "rdnLDAPAttribute": "uid", + "searchScope": "1", + "syncRegistrations": "false", + "trustEmail": "false", + "useKerberosForPasswordAuthentication": "false", + "useTruststoreSpi": "ldapsOnly", + "userObjectClasses": "inetOrgPerson, organizationalPerson", + "usernameLDAPAttribute": "uid", + "usersDn": "ou=Users,dc=example,dc=com", + "uuidLDAPAttribute": "entryUUID", + "validatePasswordPolicy": "false", + "vendor": "other" + }, + "id": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "mappers": [ + { + "config": { + "always.read.value.from.ldap": "false", + "is.mandatory.in.ldap": "false", + "ldap.attribute": "mail", + "read.only": "true", + "user.model.attribute": "email" + }, + "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f", + "name": "email", + "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f", + "providerId": "user-attribute-ldap-mapper", + "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper" + } + ], + "name": "myfed", + "parentId": "myrealm", + "providerId": "ldap", + "providerType": "org.keycloak.storage.UserStorageProvider" + } + +end_state: + description: Representation of user federation after module execution. + returned: on success + type: dict + sample: { + "config": { + "allowPasswordAuthentication": "false", + "cachePolicy": "DEFAULT", + "enabled": "true", + "kerberosRealm": "EXAMPLE.COM", + "keyTab": "/etc/krb5.keytab", + "priority": "0", + "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM", + "updateProfileFirstLogin": "false" + }, + "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122", + "mappers": [], + "name": "kerberos", + "parentId": "myrealm", + "providerId": "kerberos", + "providerType": "org.keycloak.storage.UserStorageProvider" + } +''' + +from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.six.moves.urllib.parse import urlencode +from copy import deepcopy + + +def sanitize(comp): + compcopy = deepcopy(comp) + if 'config' in compcopy: + compcopy['config'] = dict((k, v[0]) for k, v in compcopy['config'].items()) + if 'bindCredential' in compcopy['config']: + compcopy['config']['bindCredential'] = '**********' + if 'mappers' in compcopy: + for mapper in compcopy['mappers']: + if 'config' in mapper: + mapper['config'] = dict((k, v[0]) for k, v in mapper['config'].items()) + return compcopy + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + config_spec = dict( + allowKerberosAuthentication=dict(type='bool', default=False), + allowPasswordAuthentication=dict(type='bool'), + authType=dict(type='str', choices=['none', 'simple'], default='none'), + batchSizeForSync=dict(type='int', default=1000), + bindCredential=dict(type='str', no_log=True), + bindDn=dict(type='str'), + cachePolicy=dict(type='str', choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE'], default='DEFAULT'), + changedSyncPeriod=dict(type='int', default=-1), + connectionPooling=dict(type='bool', default=True), + connectionPoolingAuthentication=dict(type='str', choices=['none', 'simple', 'DIGEST-MD5']), + connectionPoolingDebug=dict(type='str'), + connectionPoolingInitSize=dict(type='int'), + connectionPoolingMaxSize=dict(type='int'), + connectionPoolingPrefSize=dict(type='int'), + connectionPoolingProtocol=dict(type='str'), + connectionPoolingTimeout=dict(type='int'), + connectionTimeout=dict(type='int'), + connectionUrl=dict(type='str'), + customUserSearchFilter=dict(type='str'), + debug=dict(type='bool'), + editMode=dict(type='str', choices=['READ_ONLY', 'WRITABLE', 'UNSYNCED']), + enabled=dict(type='bool', default=True), + evictionDay=dict(type='str'), + evictionHour=dict(type='str'), + evictionMinute=dict(type='str'), + fullSyncPeriod=dict(type='int', default=-1), + importEnabled=dict(type='bool', default=True), + kerberosRealm=dict(type='str'), + keyTab=dict(type='str', no_log=False), + maxLifespan=dict(type='int'), + pagination=dict(type='bool', default=True), + priority=dict(type='int', default=0), + rdnLDAPAttribute=dict(type='str'), + readTimeout=dict(type='int'), + searchScope=dict(type='str', choices=['1', '2'], default='1'), + serverPrincipal=dict(type='str'), + startTls=dict(type='bool', default=False), + syncRegistrations=dict(type='bool', default=False), + trustEmail=dict(type='bool', default=False), + updateProfileFirstLogin=dict(type='bool'), + useKerberosForPasswordAuthentication=dict(type='bool', default=False), + usePasswordModifyExtendedOp=dict(type='bool', default=False, no_log=False), + useTruststoreSpi=dict(type='str', choices=['always', 'ldapsOnly', 'never'], default='ldapsOnly'), + userObjectClasses=dict(type='str'), + usernameLDAPAttribute=dict(type='str'), + usersDn=dict(type='str'), + uuidLDAPAttribute=dict(type='str'), + validatePasswordPolicy=dict(type='bool', default=False), + vendor=dict(type='str'), + ) + + mapper_spec = dict( + id=dict(type='str'), + name=dict(type='str'), + parentId=dict(type='str'), + providerId=dict(type='str'), + providerType=dict(type='str', default='org.keycloak.storage.ldap.mappers.LDAPStorageMapper'), + config=dict(type='dict'), + ) + + meta_args = dict( + config=dict(type='dict', options=config_spec), + state=dict(type='str', default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + id=dict(type='str'), + name=dict(type='str'), + provider_id=dict(type='str', aliases=['providerId'], choices=['ldap', 'kerberos', 'sssd']), + provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), + parent_id=dict(type='str', aliases=['parentId']), + mappers=dict(type='list', elements='dict', options=mapper_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['id', 'name'], + ['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + state = module.params.get('state') + config = module.params.get('config') + mappers = module.params.get('mappers') + cid = module.params.get('id') + name = module.params.get('name') + + # Keycloak API expects config parameters to be arrays containing a single string element + if config is not None: + module.params['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) + for k, v in config.items() if config[k] is not None) + + if mappers is not None: + for mapper in mappers: + if mapper.get('config') is not None: + mapper['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v]) + for k, v in mapper['config'].items() if mapper['config'][k] is not None) + + # Filter and map the parameters names that apply + comp_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers'] and + module.params.get(x) is not None] + + # See if it already exists in Keycloak + if cid is None: + found = kc.get_components(urlencode(dict(type='org.keycloak.storage.UserStorageProvider', name=name)), realm) + if len(found) > 1: + module.fail_json(msg='No ID given and found multiple user federations with name `{name}`. Cannot continue.'.format(name=name)) + before_comp = next(iter(found), None) + if before_comp is not None: + cid = before_comp['id'] + else: + before_comp = kc.get_component(cid, realm) + + if before_comp is None: + before_comp = {} + + # if user federation exists, get associated mappers + if cid is not None and before_comp: + before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name')) + + # Build a proposed changeset from parameters given to this module + changeset = {} + + for param in comp_params: + new_param_value = module.params.get(param) + old_value = before_comp[camel(param)] if camel(param) in before_comp else None + if param == 'mappers': + new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value] + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # special handling of mappers list to allow change detection + if module.params.get('mappers') is not None: + if module.params['provider_id'] in ['kerberos', 'sssd']: + module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) + for change in module.params['mappers']: + change = dict((k, v) for k, v in change.items() if change[k] is not None) + if change.get('id') is None and change.get('name') is None: + module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.') + if cid is None: + old_mapper = {} + elif change.get('id') is not None: + old_mapper = kc.get_component(change['id'], realm) + if old_mapper is None: + old_mapper = {} + else: + found = kc.get_components(urlencode(dict(parent=cid, name=change['name'])), realm) + if len(found) > 1: + module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name'])) + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = {} + new_mapper = old_mapper.copy() + new_mapper.update(change) + if new_mapper != old_mapper: + if changeset.get('mappers') is None: + changeset['mappers'] = list() + changeset['mappers'].append(new_mapper) + + # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) + desired_comp = before_comp.copy() + desired_comp.update(changeset) + + result['proposed'] = sanitize(changeset) + result['existing'] = sanitize(before_comp) + + # Cater for when it doesn't exist (an empty dict) + if not before_comp: + if state == 'absent': + # Do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = {} + result['msg'] = 'User federation does not exist; doing nothing.' + module.exit_json(**result) + + # Process a creation + result['changed'] = True + + if module._diff: + result['diff'] = dict(before='', after=sanitize(desired_comp)) + + if module.check_mode: + module.exit_json(**result) + + # create it + desired_comp = desired_comp.copy() + updated_mappers = desired_comp.pop('mappers', []) + after_comp = kc.create_component(desired_comp, realm) + + cid = after_comp['id'] + + for mapper in updated_mappers: + found = kc.get_components(urlencode(dict(parent=cid, name=mapper['name'])), realm) + if len(found) > 1: + module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=mapper['name'])) + if len(found) == 1: + old_mapper = found[0] + else: + old_mapper = {} + + new_mapper = old_mapper.copy() + new_mapper.update(mapper) + + if new_mapper.get('id') is not None: + kc.update_component(new_mapper, realm) + else: + if new_mapper.get('parentId') is None: + new_mapper['parentId'] = after_comp['id'] + mapper = kc.create_component(new_mapper, realm) + + after_comp['mappers'] = updated_mappers + result['end_state'] = sanitize(after_comp) + + result['msg'] = "User federation {id} has been created".format(id=after_comp['id']) + module.exit_json(**result) + + else: + if state == 'present': + # Process an update + + # no changes + if desired_comp == before_comp: + result['changed'] = False + result['end_state'] = sanitize(desired_comp) + result['msg'] = "No changes required to user federation {id}.".format(id=cid) + module.exit_json(**result) + + # doing an update + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_comp), after=sanitize(desired_comp)) + + if module.check_mode: + module.exit_json(**result) + + # do the update + desired_comp = desired_comp.copy() + updated_mappers = desired_comp.pop('mappers', []) + kc.update_component(desired_comp, realm) + after_comp = kc.get_component(cid, realm) + + for mapper in updated_mappers: + if mapper.get('id') is not None: + kc.update_component(mapper, realm) + else: + if mapper.get('parentId') is None: + mapper['parentId'] = desired_comp['id'] + mapper = kc.create_component(mapper, realm) + + after_comp['mappers'] = updated_mappers + result['end_state'] = sanitize(after_comp) + + result['msg'] = "User federation {id} has been updated".format(id=cid) + module.exit_json(**result) + + elif state == 'absent': + # Process a deletion + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_comp), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete it + kc.delete_component(cid, realm) + + result['end_state'] = {} + + result['msg'] = "User federation {id} has been deleted".format(id=cid) + + module.exit_json(**result) + + +if __name__ == '__main__': + main()