diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index d4855edc8c3237..4658f53d6cf871 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -30,21 +30,69 @@ __metaclass__ = type import json +import urllib from ansible.module_utils.urls import open_url from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils.six.moves.urllib.error import HTTPError +from ansible.module_utils._text import to_text URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" -URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_CLIENT_SECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret" +URL_CLIENT_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings" +URL_CLIENT_REALM_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/realm" +URL_CLIENT_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients" +URL_CLIENT_CLIENT_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{client_id}" URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" + URL_GROUPS = "{url}/admin/realms/{realm}/groups" URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" +URL_GROUP_CLIENT_ROLE_MAPPING = "{url}/admin/realms/{realm}/groups/{groupid}/role-mappings/clients/{clientid}" +URL_GROUP_REALM_ROLE_MAPPING = "{url}/admin/realms/{realm}/groups/{groupid}/role-mappings/realm" + +URL_COMPONENTS = "{url}/admin/realms/{realm}/components" +URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}" +URL_COMPONENT_BY_NAME_TYPE_PARENT = "{url}/admin/realms/{realm}/components?name={name}&type={type}&parent={parent}" +URL_SUB_COMPONENTS = "{url}/admin/realms/{realm}/components?parent={parent}" + +URL_USERS = "{url}/admin/realms/{realm}/users" +URL_USER = "{url}/admin/realms/{realm}/users/{id}" +URL_USER_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings" +URL_USER_REALM_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/realm" +URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients" +URL_USER_CLIENT_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client_id}" +URL_USER_GROUPS = "{url}/admin/realms/{realm}/users/{id}/groups" +URL_USER_GROUP = "{url}/admin/realms/{realm}/users/{id}/groups/{group_id}" + +URL_USER_STORAGE = "{url}/admin/realms/{realm}/user-storage" +URL_USER_STORAGE_SYNC = "{url}/admin/realms/{realm}/user-storage/{id}/sync?action={action}" +URL_USER_STORAGE_MAPPER_SYNC = "{url}/admin/realms/{realm}/user-storage/{parentid}/mappers/{id}/sync?direction={direction}" + +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_EXECUTIONS_CONFIG = "{url}/admin/realms/{realm}/authentication/executions/{id}/config" +URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" + +URL_IDPS = "{url}/admin/realms/{realm}/identity-provider/instances" +URL_IDP = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" +URL_IDP_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers" +URL_IDP_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}" + +URL_REALMS = "{url}/admin/realms" +URL_REALM = "{url}/admin/realms/{realm}" +URL_REALM_EVENT_CONFIG = "{url}/admin/realms/{realm}/events/config" + +URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" +URL_REALM_ROLE = "{url}/admin/realms/{realm}/roles/{name}" +URL_REALM_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/roles/{name}/composites" def keycloak_argument_spec(): @@ -54,12 +102,12 @@ def keycloak_argument_spec(): :return: argument_spec dict """ return dict( - auth_keycloak_url=dict(type='str', aliases=['url'], required=True), + auth_keycloak_url=dict(type='str', required=True), auth_client_id=dict(type='str', default='admin-cli'), - auth_realm=dict(type='str', required=True), + auth_realm=dict(type='str', default='master'), auth_client_secret=dict(type='str', default=None), - auth_username=dict(type='str', aliases=['username'], required=True), - auth_password=dict(type='str', aliases=['password'], required=True, no_log=True), + auth_username=dict(type='str', required=True), + auth_password=dict(type='str', required=True, no_log=True), validate_certs=dict(type='bool', default=True) ) @@ -68,6 +116,114 @@ def camel(words): return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) +def remove_arguments_with_value_none(argument): + """ + This function remove all NoneType elements from dict object. + This is useful when argument_spec include optional keys which dos not need to be + POST or PUT to Keycloak API. + :param argument: Dict from which to remove NoneType elements + :return: nothing + """ + if type(argument) is dict: + for key in argument.keys(): + if argument[key] is None: + del argument[key] + elif type(argument[key]) is list: + for element in argument[key]: + remove_arguments_with_value_none(element) + elif type(argument[key]) is dict: + remove_arguments_with_value_none(argument[key]) + + +def isDictEquals(dict1, dict2, exclude=None): + """ + This function compare if tthe first parameter structure, is included in the second. + The function use every elements of dict1 and validates they are present in the dict2 structure. + The two structure does not need to be equals for that function to return true. + Each elements are compared recursively. + :param dict1: + type: + dict for the initial call, can be dict, list, bool, int or str for recursive calls + description: + reference structure + :param dict2: + 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. + """ + try: + if type(dict1) is list and type(dict2) is list: + if len(dict1) == 0 and len(dict2) == 0: + return True + for item1 in dict1: + found = False + if type(item1) is list: + found1 = False + for item2 in dict2: + if isDictEquals(item1, item2, exclude): + found1 = True + if found1: + found = True + elif type(item1) is dict: + found1 = False + for item2 in dict2: + if isDictEquals(item1, item2, exclude): + found1 = True + if found1: + found = True + else: + if item1 not in dict2: + return False + else: + found = True + if not found: + return False + return found + elif type(dict1) is dict and type(dict2) is dict: + if len(dict1) == 0 and len(dict2) == 0: + return True + for key in dict1: + if not (exclude and key in exclude): + if not isDictEquals(dict1[key], dict2[key], exclude): + return False + return True + elif type(dict1) is bool and type(dict2) is bool: + return dict1 == dict2 + else: + return to_text(dict1, 'utf-8') == to_text(dict2, 'utf-8') + except KeyError: + return False + + +def ansible2keycloakClientRoles(ansibleClientRoles): + keycloakClientRoles = {} + for clientRoles in ansibleClientRoles: + keycloakClientRoles[clientRoles["clientid"]] = clientRoles["roles"] + return keycloakClientRoles + + +def keycloak2ansibleClientRoles(keycloakClientRoles): + ansibleClientRoles = [] + for client in keycloakClientRoles.keys(): + role = {} + role["clientid"] = client + role["roles"] = keycloakClientRoles[client] + ansibleClientRoles.append(role) + return ansibleClientRoles + + 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 @@ -141,7 +297,11 @@ def get_client_by_clientid(self, client_id, realm='master'): """ r = self.get_clients(realm=realm, filter=client_id) if len(r) > 0: - return r[0] + clientrep = r[0] + clients_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=clientrep['id']) + self.add_client_roles_to_representation(clients_url, client_roles_url, clientrep) + return clientrep else: return None @@ -153,10 +313,13 @@ def get_client_by_id(self, id, realm='master'): :return: dict of client representation or None if none matching exist """ client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) - + clients_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=id) try: - return json.load(open_url(client_url, method='GET', headers=self.restheaders, - validate_certs=self.validate_certs)) + clientrep = json.load(open_url(client_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + self.add_client_roles_to_representation(clients_url, client_roles_url, clientrep) + return clientrep except HTTPError as e: if e.code == 404: @@ -171,9 +334,38 @@ def get_client_by_id(self, id, realm='master'): self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' % (id, realm, str(e))) + def get_client_secret_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) + client_secret_url = URL_CLIENT_SECRET.format(url=self.baseurl, realm=realm, id=id) + try: + clientrep = json.load(open_url(client_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + if clientrep[camel('public_client')]: + clientsecretrep = None + else: + clientsecretrep = json.load(open_url(client_secret_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + return clientsecretrep + 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) @@ -192,10 +384,29 @@ def update_client(self, id, clientrep, realm="master"): :return: HTTPResponse object on success """ client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) - try: - return open_url(client_url, method='PUT', headers=self.restheaders, - data=json.dumps(clientrep), validate_certs=self.validate_certs) + client_roles = None + if camel('client_roles') in clientrep: + client_roles = clientrep[camel('client_roles')] + del(clientrep[camel('client_roles')]) + if camel('scope_mappings') in clientrep: + del(clientrep[camel('scope_mappings')]) + client_protocol_mappers = None + if camel('protocol_mappers') in clientrep: + client_protocol_mappers = clientrep[camel('protocol_mappers')] + del(clientrep[camel('protocol_mappers')]) + putResponse = open_url(client_url, method='PUT', headers=self.restheaders, + data=json.dumps(clientrep), validate_certs=self.validate_certs) + if client_protocol_mappers is not None: + clientrep[camel('protocol_mappers')] = client_protocol_mappers + self.create_or_update_client_mappers(client_url, clientrep) + if client_roles is not None: + self.create_or_update_client_roles( + clientrep[camel('client_id')], + client_roles, + realm) + return putResponse + except Exception as e: self.module.fail_json(msg='Could not update client %s in realm %s: %s' % (id, realm, str(e))) @@ -206,11 +417,32 @@ def create_client(self, clientrep, realm="master"): :param realm: realm for client to be created :return: HTTPResponse object on success """ - client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) - + clients_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) try: - return open_url(client_url, method='POST', headers=self.restheaders, - data=json.dumps(clientrep), validate_certs=self.validate_certs) + client_roles = None + if camel('client_roles') in clientrep: + client_roles = clientrep[camel('client_roles')] + del(clientrep[camel('client_roles')]) + if camel('scope_mappings') in clientrep: + del(clientrep[camel('scope_mappings')]) + client_protocol_mappers = None + if camel('protocol_mappers') in clientrep: + client_protocol_mappers = clientrep[camel('protocol_mappers')] + del(clientrep[camel('protocol_mappers')]) + postResponse = open_url(clients_url, method='POST', headers=self.restheaders, + data=json.dumps(clientrep), validate_certs=self.validate_certs) + client_url = URL_CLIENT.format(url=self.baseurl, + realm=realm, + id=self.get_client_id(clientrep[camel('client_id')], realm)) + if client_protocol_mappers is not None: + clientrep[camel('protocol_mappers')] = client_protocol_mappers + self.create_or_update_client_mappers(client_url, clientrep) + if client_roles is not None: + self.create_or_update_client_roles( + clientrep[camel('client_id')], + client_roles, + realm) + return postResponse except Exception as e: self.module.fail_json(msg='Could not create client %s in realm %s: %s' % (clientrep['clientId'], realm, str(e))) @@ -342,6 +574,248 @@ def delete_client_template(self, id, realm="master"): self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) + def get_client_scope_mappings_realm_roles(self, client_id, realm='master'): + """ + Get realm roles for a Client. + :param client_id: Client ID + :param realm: Realm + :return: Representation of the realm roles. + """ + try: + scope_mappings_url = URL_CLIENT_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id) + scope_mappings = json.load( + open_url( + scope_mappings_url, + method='GET', + headers=self.restheaders)) + realmRoles = [] + if "realmMappings" in scope_mappings: + for scopeMapping in scope_mappings["realmMappings"]: + realmRoles.append(scopeMapping["name"]) + return realmRoles + except Exception as e: + self.module.fail_json(msg='Could not get scope mappings realm_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def update_client_scope_mappings_realm_roles(self, client_id, realmRolesRepresentation, realm='master'): + """ + Update realm roles for a Client. + :param client_id: Client ID + :param realm: Realm + :return: Representation of the realm roles. + """ + try: + scope_mappings_url = URL_CLIENT_REALM_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id) + return open_url( + scope_mappings_url, + method='POST', + headers=self.restheaders, + data=json.dumps(realmRolesRepresentation)) + except Exception as e: + self.module.fail_json(msg='Could not update scope mappings realm_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def delete_client_scope_mappings_realm_roles(self, client_id, realmRolesRepresentation, realm='master'): + """ + delete realm roles for a Client. + :param client_id: Client ID + :param realm: Realm + :return: Representation of the realm roles. + """ + try: + scope_mappings_url = URL_CLIENT_REALM_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id) + return open_url( + scope_mappings_url, + method='DELETE', + headers=self.restheaders, + data=json.dumps(realmRolesRepresentation)) + except Exception as e: + self.module.fail_json(msg='Could not delete scope mappings realm_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def get_client_scope_mappings_client_roles(self, client_id, realm='master'): + """ + Get client roles for a client. + :param client_id: Client ID + :param realm: Realm + :return: Representation of the client roles. + """ + try: + clientRoles = [] + scope_mappings_url = URL_CLIENT_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id) + scope_mappings = json.load( + open_url( + scope_mappings_url, + method='GET', + headers=self.restheaders)) + if "clientMappings" in scope_mappings: + for clientMapping in scope_mappings["clientMappings"].keys(): + clientRole = {} + clientRole["clientId"] = scope_mappings["clientMappings"][clientMapping]["client"] + roles = [] + for role in scope_mappings["clientMappings"][clientMapping]["mappings"]: + roles.append(role["name"]) + clientRole["roles"] = roles + clientRoles.append(clientRole) + return clientRoles + except Exception as e: + self.module.fail_json(msg='Could not get scope mappings client_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def delete_client_scope_mappings_client_roles(self, client_id, clientscope_id, realm='master'): + """ + Delete client roles for a client. + :param client_id: Client ID + :param clientscope_id: Client ID for client roles to delete. + :param realm: Realm + :return: HTTP Response. + """ + try: + scope_mappings_url = URL_CLIENT_CLIENT_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id, + client_id=clientscope_id) + return open_url( + scope_mappings_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete scope mappings client_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def create_client_scope_mappings_client_roles(self, client_id, clientscope_id, rolesToAssing, realm='master'): + """ + Delete client roles for a client. + :param client_id: Client ID + :param clientscope_id: Client ID for client roles to create. + :param rolesToAssing: Representation of the client roles to create. + :param realm: Realm + :return: HTTP Response. + """ + try: + scope_mappings_url = URL_CLIENT_CLIENT_SCOPE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=client_id, + client_id=clientscope_id) + return open_url( + scope_mappings_url, + method='POST', + headers=self.restheaders, + data=json.dumps(rolesToAssing)) + except Exception as e: + self.module.fail_json(msg='Could not create scope mappings client_roles for client %s in realm %s: %s' + % (client_id, realm, str(e))) + + def assing_scope_roles_to_client(self, client_id, clientScopeRealmRoles, clientScopeClientRoles, realm='master'): + """ + Assign roles to a client. + :param client_id: client ID to whom assign roles. + :param clientScopeRealmRoles: Realm roles to assign to client. + :param clientScopeClientRoles: Client roles to assign to client. + :param realm: Realm + :return: True is client's role have changed, False otherwise. + """ + try: + # Get the new created client realm roles + newClientScopeRealmRoles = self.get_client_scope_mappings_realm_roles( + client_id=client_id, + realm=realm) + # Get the new created client client roles + newClientScopeClientRoles = self.get_client_scope_mappings_client_roles( + client_id=client_id, + realm=realm) + changed = False + # Assign Realm Roles + realmRolesRepresentation = [] + # Get all realm roles + allRealmRoles = self.get_realm_roles(realm=realm) + for realmRole in clientScopeRealmRoles: + # Look for existing role into client representation + if realmRole["name"] not in newClientScopeRealmRoles: + roleid = None + # Find the role id + for role in allRealmRoles: + if role["name"] == realmRole["name"]: + roleid = role["id"] + break + if roleid is not None: + realmRoleRepresentation = {} + realmRoleRepresentation["id"] = roleid + realmRoleRepresentation["name"] = realmRole["name"] + realmRolesRepresentation.append(realmRoleRepresentation) + elif realmRole["state"] == 'absent': + roleid = None + for role in allRealmRoles: + if role["name"] == realmRole["name"]: + roleid = role["id"] + break + if roleid is not None: + delRealmRoleRepresentation = {} + delRealmRoleRepresentation["id"] = roleid + delRealmRoleRepresentation["name"] = realmRole["name"] + self.delete_client_scope_mappings_realm_roles( + client_id=client_id, + realmRolesRepresentation=delRealmRoleRepresentation, + realm=realm) + if len(realmRolesRepresentation) > 0: + # Assign Role + self.update_client_scope_mappings_realm_roles( + client_id=client_id, + realmRolesRepresentation=realmRolesRepresentation, + realm=realm) + changed = True + # Assign clients roles if they need changes + if not isDictEquals(clientScopeClientRoles, newClientScopeClientRoles): + for clientToAssingRole in clientScopeClientRoles: + # Get the client roles + clientscope_id = self.get_client_by_clientid( + client_id=clientToAssingRole["id"], + realm=realm)['id'] + clientRoles = self.get_client_roles( + client_id=clientscope_id, + realm=realm) + if clientRoles != {}: + rolesToAssing = [] + for roleToAssing in clientToAssingRole["roles"]: + newRole = {} + # Find his Id + for clientRole in clientRoles: + if clientRole["name"] == roleToAssing["name"]: + newRole["id"] = clientRole["id"] + newRole["name"] = roleToAssing["name"] + rolesToAssing.append(newRole) + if len(rolesToAssing) > 0: + # Delete exiting client Roles + self.delete_client_scope_mappings_client_roles( + client_id=client_id, + clientscope_id=clientscope_id, + realm=realm) + # Assign Role + self.create_client_scope_mappings_client_roles( + client_id=client_id, + clientscope_id=clientscope_id, + rolesToAssing=rolesToAssing, + realm=realm) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not assign roles to client %s in realm %s: %s' + % (client_id, realm, str(e))) + def get_groups(self, realm="master"): """ Fetch the name and ID of all groups on the Keycloak server. @@ -352,8 +826,9 @@ def get_groups(self, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, - validate_certs=self.validate_certs)) + grouprep = json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + return grouprep except Exception as e: self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" % (realm, str(e))) @@ -369,9 +844,13 @@ def get_group_by_groupid(self, gid, realm="master"): """ groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) try: - return json.load(open_url(groups_url, method="GET", headers=self.restheaders, - validate_certs=self.validate_certs)) + grouprep = json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + if "clientRoles" in grouprep: + tmpClientRoles = grouprep["clientRoles"] + grouprep["clientRoles"] = keycloak2ansibleClientRoles(tmpClientRoles) + return grouprep except HTTPError as e: if e.code == 404: return None @@ -415,8 +894,17 @@ def create_group(self, grouprep, realm="master"): """ groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) try: + # Remove roles because they are not supported by the POST method of the Keycloak endpoint. + groupreptocreate = grouprep.copy() + if "realmRoles" in groupreptocreate: + del(groupreptocreate['realmRoles']) + if "clientRoles" in groupreptocreate: + del(groupreptocreate['clientRoles']) + # Remove the id if it is defined. This can happen when force is true. + if "id" in groupreptocreate: + del(groupreptocreate['id']) return open_url(groups_url, method='POST', headers=self.restheaders, - data=json.dumps(grouprep), validate_certs=self.validate_certs) + data=json.dumps(groupreptocreate), 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))) @@ -430,8 +918,14 @@ def update_group(self, grouprep, realm="master"): group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) try: + # remove roles because they are not supported by the PUT method of the Keycloak endpoint. + groupreptoupdate = grouprep.copy() + if "realmRoles" in groupreptoupdate: + del(groupreptoupdate['realmRoles']) + if "clientRoles" in groupreptoupdate: + del(groupreptoupdate['clientRoles']) return open_url(group_url, method='PUT', headers=self.restheaders, - data=json.dumps(grouprep), validate_certs=self.validate_certs) + data=json.dumps(groupreptoupdate), 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))) @@ -472,3 +966,2048 @@ def delete_group(self, name=None, groupid=None, realm="master"): except Exception as e: self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) + + def get_client_roles(self, client_id, realm='master'): + """ Get all client's roles + + :param client_id: id of the client + :return: Client's roles representation is added to the client representation as clientRoles key + """ + try: + client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, + realm=realm, + id=client_id) + clientRolesRepresentation = json.load(open_url(client_roles_url, + method='GET', + headers=self.restheaders)) + return clientRolesRepresentation + except Exception as e: + self.module.fail_json(msg="Unable to get client's %s roles in realm %s: %s" % (client_id, realm, str(e))) + + def add_client_roles_to_representation(self, clientSvcBaseUrl, clientRolesUrl, clientRepresentation): + """ Add client roles and their composites to the client representation in order to return this information to the user + + :param clientSvcBaseUrl: url of the client + :param clientRolesUrl: url of the client roles + :param clientRepresentation: actual representation of the client + :return: nothing, the roles representation is added to the client representation as clientRoles key + """ + try: + clientRolesRepresentation = json.load(open_url(clientRolesUrl, method='GET', headers=self.restheaders)) + for clientRole in clientRolesRepresentation: + if clientRole["composite"]: + clientRole["composites"] = json.load( + open_url( + clientRolesUrl + '/' + clientRole['name'] + '/composites', + method='GET', + headers=self.restheaders)) + for roleComposite in clientRole["composites"]: + if roleComposite['clientRole']: + roleCompositeClient = json.load( + open_url( + clientSvcBaseUrl + '/' + roleComposite['containerId'], + method='GET', + headers=self.restheaders)) + roleComposite["clientId"] = roleCompositeClient["clientId"] + clientRepresentation['clientRoles'] = clientRolesRepresentation + except Exception as e: + self.module.fail_json(msg="Unable to add client roles %s: %s" % (clientRepresentation["id"], str(e))) + + def create_or_update_client_roles(self, client_id, newClientRoles, realm='master'): + """ Create or update client roles. Client roles can be added, updated or removed depending of the state. + + :param newClientRoles: Client roles to be added, updated or removed. + :param roleSvcBaseUrl: Url to query realm roles + :param clientSvcBaseUrl: Url to list clients + :param clientRolesUrl: Url of the actual client roles + :return: True if the client roles have changed, False otherwise + """ + id_client = self.get_client_by_clientid(client_id=client_id, realm=realm)["id"] + clientRolesUrl = URL_CLIENT_ROLES.format(url=self.baseurl, + realm=realm, + id=id_client) + try: + changed = False + # Manage the roles + if newClientRoles is not None: + for newClientRole in newClientRoles: + changeNeeded = False + desiredState = "present" + # If state key is included in the client role representation, save its value and remove the key from the representation. + if "state" in newClientRole: + desiredState = newClientRole["state"] + del(newClientRole["state"]) + if 'composites' in newClientRole and newClientRole['composites'] is not None: + newComposites = newClientRole['composites'] + for newComposite in newComposites: + if "id" in newComposite and newComposite["id"] is not None: + # Get the id of client for this role + role_client = self.get_client_by_clientid(client_id=newComposite["id"], realm=realm) + if role_client is None: + self.module.fail_json(msg="Unable to create or update client roles, client %s does not exist" % (newComposite["id"])) + else: + for role in self.get_client_roles(client_id=role_client["id"], realm=realm): + if role["name"] == newComposite["name"]: + newComposite['id'] = role['id'] + newComposite['clientRole'] = True + break + else: + for realmRole in self.get_realm_roles(realm=realm): + if realmRole["name"] == newComposite["name"]: + newComposite['id'] = realmRole['id'] + newComposite['clientRole'] = False + break + clientRoleFound = False + clientRoles = self.get_client_roles(client_id=id_client, realm=realm) + if len(clientRoles) > 0: + # Check if role to be created already exist for the client + for clientRole in clientRoles: + if (clientRole['name'] == newClientRole['name']): + clientRoleFound = True + break + # If we have to create the role because it does not exist and the desired state is present, or it exists and the desired state is absent + if (not clientRoleFound and desiredState != "absent") or (clientRoleFound and desiredState == "absent"): + changeNeeded = True + else: + if "composites" in newClientRole and newClientRole['composites'] is not None: + excludes = [] + excludes.append("composites") + if not isDictEquals(newClientRole, clientRole, excludes): + changeNeeded = True + else: + for newComposite in newClientRole['composites']: + compositeFound = False + if 'composites' not in clientRole or clientRole['composites'] is None: + changeNeeded = True + break + for existingComposite in clientRole['composites']: + if isDictEquals(newComposite, existingComposite): + compositeFound = True + break + if not compositeFound: + changeNeeded = True + break + else: + if not isDictEquals(newClientRole, clientRole): + changeNeeded = True + elif desiredState != "absent": + changeNeeded = True + if changeNeeded and desiredState != "absent": + # If role must be modified + newRoleRepresentation = {} + newRoleRepresentation["name"] = newClientRole['name'].decode("utf-8") + newRoleRepresentation["description"] = newClientRole['description'].decode("utf-8") + newRoleRepresentation["composite"] = newClientRole['composite'] if "composite" in newClientRole else False + newRoleRepresentation["clientRole"] = newClientRole['clientRole'] if "clientRole" in newClientRole else True + data = json.dumps(newRoleRepresentation) + if clientRoleFound: + open_url(clientRolesUrl + '/' + newClientRole['name'], method='PUT', headers=self.restheaders, data=data) + else: + open_url(clientRolesUrl, method='POST', headers=self.restheaders, data=data) + changed = True + # Composites role + if 'composites' in newClientRole and newClientRole['composites'] is not None and len(newClientRole['composites']) > 0: + newComposites = newClientRole['composites'] + if clientRoleFound and "composites" in clientRole: + rolesToDelete = [] + for roleTodelete in clientRole['composites']: + tmprole = {} + tmprole['id'] = roleTodelete['id'] + rolesToDelete.append(tmprole) + open_url( + clientRolesUrl + '/' + newClientRole['name'] + '/composites', method='DELETE', + headers=self.restheaders, + data=json.dumps(rolesToDelete)) + data = json.dumps(newClientRole["composites"]) + open_url(clientRolesUrl + '/' + newClientRole['name'] + '/composites', method='POST', headers=self.restheaders, data=data) + elif changeNeeded and desiredState == "absent" and clientRoleFound: + open_url(clientRolesUrl + '/' + newClientRole['name'], method='DELETE', headers=self.restheaders) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg="Unable to create or update client roles %s: %s" % (clientRolesUrl, str(e))) + + def create_or_update_client_mappers(self, clientUrl, clientRepresentation): + """ + Create or update client protocol mappers. Mappers can be added, + updated or removed depending of the state. + :param clientUrl: Keycloak API url of the client + :param clientRepresentation: Desired representation of the client including protocolMappers list + :return: True if the client roles have changed, False otherwise + """ + try: + changed = False + if camel('protocol_mappers') in clientRepresentation and clientRepresentation[camel('protocol_mappers')] is not None: + newClientProtocolMappers = clientRepresentation[camel('protocol_mappers')] + # Get existing mappers from the client + clientMappers = json.load( + open_url( + clientUrl + '/protocol-mappers/models', + method='GET', + headers=self.restheaders)) + for newClientProtocolMapper in newClientProtocolMappers: + desiredState = "present" + # If state key is included in the mapper representation, save its value and remove the key from the representation. + if "state" in newClientProtocolMapper: + desiredState = newClientProtocolMapper["state"] + del(newClientProtocolMapper["state"]) + clientMapperFound = False + # Check if mapper already exist for the client + for clientMapper in clientMappers: + if (clientMapper['name'] == newClientProtocolMapper['name']): + clientMapperFound = True + break + # If mapper exists for the client + if clientMapperFound: + if desiredState == "absent": + # Delete the mapper + open_url(clientUrl + '/protocol-mappers/models/' + clientMapper['id'], method='DELETE', headers=self.restheaders) + changed = True + else: + if not isDictEquals(newClientProtocolMapper, clientMapper): + # If changed has been introduced for the mapper + changed = True + newClientProtocolMapper["id"] = clientMapper["id"] + # Modify the mapper + open_url( + clientUrl + '/protocol-mappers/models/' + clientMapper['id'], + method='PUT', + headers=self.restheaders, + data=json.dumps(newClientProtocolMapper)) + else: # If mapper does not exist for the client + if desiredState != "absent": + # Create the mapper + open_url( + clientUrl + '/protocol-mappers/models', + method='POST', + headers=self.restheaders, + data=json.dumps(newClientProtocolMapper)) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg="Unable to create or update client mappers %s: %s" + % (clientRepresentation["id"], str(e))) + + def add_attributes_list_to_attributes_dict(self, AttributesList, AttributesDict): + """ + Add items form an attribute list which is not a Keycloak standard to as an attribute dict. + :param AttributesList: List of attribute to add + :param AttributesDict: Dict of attributes in which to add the list + :return: nothing + """ + if AttributesList is not None: + if AttributesDict is None: + AttributesDict = {} + for attr in AttributesList: + if "name" in attr and attr["name"] is not None and "value" in attr: + AttributesDict[attr["name"]] = attr["value"] + + def assing_roles_to_group(self, groupRepresentation, groupRealmRoles, groupClientRoles, realm='master'): + """ + Assing roles to group. Roles can be composites of other roles. + Composites can be composed by realm and client roles. + Every member of the group will inherit those roles. + :param groupRepresentation: Representation of the group to assign roles + :param groupRealmRoles: Realm roles to assign to group + :param groupClientRoles: Clients roles to assign to group. + :param realm: Realm + :return: True if roles have been assigned or revoked to the group. False otherwise. + """ + try: + roleSvcBaseUrl = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) + clientSvcBaseUrl = URL_CLIENTS.format(url=self.baseurl, realm=realm) + # Get the id of the group + if 'id' in groupRepresentation: + gid = groupRepresentation['id'] + else: + gid = self.get_group_by_name(name=groupRepresentation['name'], realm=realm)['id'] + changed = False + # Assing Realm Roles + realmRolesRepresentation = [] + if groupRealmRoles is not None: + for realmRole in groupRealmRoles: + # Look for existing role into group representation + if "realmRoles" not in groupRepresentation or realmRole not in groupRepresentation["realmRoles"]: + roleid = None + # Get all realm roles + realmRoles = json.load(open_url(roleSvcBaseUrl, method='GET', headers=self.restheaders)) + # Find the role id + for role in realmRoles: + if role["name"] == realmRole: + roleid = role["id"] + break + if roleid is not None: + realmRoleRepresentation = {} + realmRoleRepresentation["id"] = roleid + realmRoleRepresentation["name"] = realmRole + realmRolesRepresentation.append(realmRoleRepresentation) + if len(realmRolesRepresentation) > 0: + # Assing Role + open_url( + URL_GROUP_REALM_ROLE_MAPPING.format(url=self.baseurl, + realm=realm, + groupid=gid), + method='POST', + headers=self.restheaders, + data=json.dumps(realmRolesRepresentation)) + changed = True + if groupClientRoles is not None: + # If there is change to do for client roles + if "clientRoles" not in groupRepresentation or not isDictEquals(groupClientRoles, + groupRepresentation["clientRoles"]): + # Assing clients roles + for clientRolesToAssing in groupClientRoles: + rolesToAssing = [] + clientIdOfClientRole = clientRolesToAssing['clientid'] + # Get the id of the client + clients = json.load( + open_url( + clientSvcBaseUrl + '?clientId=' + clientIdOfClientRole, + method='GET', + headers=self.restheaders)) + if len(clients) > 0 and "id" in clients[0]: + clientId = clients[0]["id"] + # Get the client roles + clientRoles = json.load( + open_url( + URL_CLIENT_ROLES.format( + url=self.baseurl, + realm=realm, + id=clientId), + method='GET', + headers=self.restheaders)) + for clientRoleToAssing in clientRolesToAssing["roles"]: + # Find his Id + for clientRole in clientRoles: + if clientRole["name"] == clientRoleToAssing: + newRole = {} + newRole["id"] = clientRole["id"] + newRole["name"] = clientRole["name"] + rolesToAssing.append(newRole) + break + if len(rolesToAssing) > 0: + # Delete exiting client Roles + open_url( + URL_GROUP_CLIENT_ROLE_MAPPING.format( + url=self.baseurl, + realm=realm, + groupid=gid, + clientid=clientId), + method='DELETE', + headers=self.restheaders) + # Assing Role + open_url( + URL_GROUP_CLIENT_ROLE_MAPPING.format( + url=self.baseurl, + realm=realm, + groupid=gid, + clientid=clientId), + method='POST', + headers=self.restheaders, data=json.dumps(rolesToAssing)) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg="Unable to assign roles to group %s: %s" % (groupRepresentation['name'], str(e))) + + def sync_ldap_groups(self, direction, realm='master'): + """ + Synchronize groups between Keycloak and LDAP. Every group mappers of users storage providers will be synchronized. + The direction parameter will specify how the synchronization will be done. + :param direction: fedToKeycloak or keycloakToFed + :param realm: Realm + :return: Nothing + """ + try: + LDAPUserStorageProviderType = "org.keycloak.storage.UserStorageProvider" + componentSvcBaseUrl = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + userStorageBaseUrl = URL_USER_STORAGE.format(url=self.baseurl, realm=realm) + # Get all components of type org.keycloak.storage.UserStorageProvider + components = json.load( + open_url( + componentSvcBaseUrl + '?type=' + LDAPUserStorageProviderType, + method='GET', + headers=self.restheaders)) + for component in components: + # Get all sub components of type group-ldap-mapper + subComponents = json.load( + open_url( + componentSvcBaseUrl + "?parent=" + component["id"] + "&providerId=group-ldap-mapper", + method='GET', + headers=self.restheaders)) + # For each group mappers + for subComponent in subComponents: + if subComponent["providerId"] == 'group-ldap-mapper': + # Sync groups + open_url( + userStorageBaseUrl + + '/' + + subComponent["parentId"] + + "/mappers/" + + subComponent["id"] + + "/sync?direction=" + + direction, + method='POST', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg="Unable to sync ldap groups %s: %s" % (direction, 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: + authenticationFlow = {} + # 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', headers=self.restheaders)) + for authentication in authentications: + if authentication["alias"] == alias: + authenticationFlow = authentication + break + return authenticationFlow + 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', headers=self.restheaders, + 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: + newName = dict( + newName=config["alias"] + ) + open_url( + URL_AUTHENTICATION_FLOW_COPY.format( + url=self.baseurl, + realm=realm, + copyfrom=urllib.quote(config["copyFrom"])), + method='POST', + headers=self.restheaders, + data=json.dumps(newName)) + flowList = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format(url=self.baseurl, + realm=realm), + method='GET', + headers=self.restheaders)) + for flow in flowList: + 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: + newFlow = dict( + alias=config["alias"], + providerId=config["providerId"], + topLevel=True + ) + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='POST', + headers=self.restheaders, + data=json.dumps(newFlow)) + flowList = json.load( + open_url( + URL_AUTHENTICATION_FLOWS.format( + url=self.baseurl, + realm=realm), + method='GET', + headers=self.restheaders)) + for flow in flowList: + 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 create_or_update_executions(self, config, realm='master'): + """ + Create or update executions for an authentication flow. + :param config: Representation of the authentication flow including it's executions. + :param realm: Realm + :return: True if executions have been modified. False otherwise. + """ + try: + changed = False + if "authenticationExecutions" in config: + for newExecution in config["authenticationExecutions"]: + # Get existing executions on the Keycloak server for this alias + existingExecutions = json.load( + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=urllib.quote(config["alias"])), + method='GET', + headers=self.restheaders)) + executionFound = False + for existingExecution in existingExecutions: + if "providerId" in existingExecution and existingExecution["providerId"] == newExecution["providerId"]: + executionFound = True + break + if executionFound: + # Replace config id of the execution config by it's complete representation + if "authenticationConfig" in existingExecution: + execConfigId = existingExecution["authenticationConfig"] + execConfig = json.load( + open_url( + URL_AUTHENTICATION_CONFIG.format( + url=self.baseurl, + realm=realm, + id=execConfigId), + method='GET', + headers=self.restheaders)) + existingExecution["authenticationConfig"] = execConfig + # Compare the executions to see if it need changes + if not isDictEquals(newExecution, existingExecution): + changed = True + else: + # Create the new execution + newExec = {} + newExec["provider"] = newExecution["providerId"] + newExec["requirement"] = newExecution["requirement"] + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS_EXECUTION.format( + url=self.baseurl, + realm=realm, + flowalias=urllib.quote(config["alias"])), + method='POST', + headers=self.restheaders, + data=json.dumps(newExec)) + changed = True + if changed: + # Get existing executions on the Keycloak server for this alias + existingExecutions = json.load( + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=urllib.quote(config["alias"])), + method='GET', + headers=self.restheaders)) + executionFound = False + for existingExecution in existingExecutions: + if "providerId" in existingExecution and existingExecution["providerId"] == newExecution["providerId"]: + executionFound = True + break + if executionFound: + # Update the existing execution + updatedExec = {} + updatedExec["id"] = existingExecution["id"] + for key in newExecution: + # create the execution configuration + if key == "authenticationConfig": + # Add the autenticatorConfig to the execution + open_url( + URL_AUTHENTICATION_EXECUTIONS_CONFIG.format( + url=self.baseurl, + realm=realm, + id=existingExecution["id"]), + method='POST', + headers=self.restheaders, + data=json.dumps(newExecution["authenticationConfig"])) + else: + updatedExec[key] = newExecution[key] + open_url( + URL_AUTHENTICATION_FLOW_EXECUTIONS.format( + url=self.baseurl, + realm=realm, + flowalias=urllib.quote(config["alias"])), + method='PUT', + headers=self.restheaders, + data=json.dumps(updatedExec)) + return changed + except Exception as e: + self.module.fail_json(msg='Could not create or update executions for authentication flow %s in realm %s: %s' + % (config["alias"], realm, 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=urllib.quote(config["alias"])), + method='GET', + headers=self.restheaders)) + 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', + headers=self.restheaders)) + 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_component_by_id(self, component_id, realm='master'): + """ + Get component representation by it's ID + :param component_id: ID of the component to get + :param realm: Realm + :return: Representation of the component + """ + try: + component_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=component_id) + return json.load(open_url(component_url, method='GET', headers=self.restheaders)) + + except Exception as e: + self.module.fail_json(msg='Could not get component %s in realm %s: %s' + % (component_id, realm, str(e))) + + def get_component_by_name_provider_and_parent(self, name, provider_type, provider_id, parent_id, realm='master'): + """ + Get a component by it's name, provider type, provider id and parent + :param name: Name of the component + :param provider_type: Provider type of the component + :param provider_id: Provider ID of the component + :param parent_id: Parent ID of the component. Realm is used as parent for base component. + :param realm: Realm + :return: Component's representation if found. An empty dict otherwise. + """ + componentFound = {} + components = self.get_components_by_name_provider_and_parent( + name=name, + provider_type=provider_type, + parent_id=parent_id, + realm=realm) + for component in components: + if "providerId" in component and component["providerId"] == provider_id: + componentFound = component + break + return componentFound + + def get_components_by_name_provider_and_parent(self, name, provider_type, parent_id, realm='master'): + """ + Get components by name, provider and parent + :param name: Name of the component + :param provider_type: Provider type of the component + :param provider_id: Provider ID of the component + :param parent_id: Parent ID of the component. Realm is used as parent for base component. + :return: List of components found. + """ + try: + component_url = URL_COMPONENT_BY_NAME_TYPE_PARENT.format( + url=self.baseurl, + realm=realm, + name=name, + type=provider_type, + parent=parent_id) + components = json.load( + open_url( + component_url, + method='GET', + headers=self.restheaders)) + return components + except Exception as e: + self.module.fail_json(msg='Could not get component %s in realm %s: %s' + % (name, realm, str(e))) + + def create_component(self, newComponent, newSubComponents, syncLdapMappers, realm='master'): + """ + Create a component and it's subComponents + :param newComponent: Representation of the component to create + :param newSubComponents: List of subcomponents to create + :param syncLdapMappers: Mapper synchronization + :param realm: Realm + :return: Representation of the component created + """ + try: + component_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + open_url(component_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newComponent)) + # Get the new created component + component = self.get_component_by_name_provider_and_parent( + name=newComponent["name"], + provider_type=newComponent["providerType"], + provider_id=newComponent["providerId"], + parent_id=newComponent["parentId"], + realm=realm) + # Create Sub components + self.create_new_sub_components(component, newSubComponents, syncLdapMappers, realm=realm) + return component + except Exception as e: + self.module.fail_json(msg='Could not create component %s in realm %s: %s' + % (newComponent["name"], realm, str(e))) + + def create_new_sub_components(self, component, newSubComponents, syncLdapMappers, realm='master'): + """ + Create subcomponents for a component. + :param component: Representation of the parent component + :param newSubComponents: List of subcomponents to create for this parent + :param syncLdapMappers: Mapper synchronization + :param realm: Realm + :return: Nothing + """ + try: + # If subcomponents are defined + if newSubComponents is not None: + for componentType in newSubComponents.keys(): + for newSubComponent in newSubComponents[componentType]: + newSubComponent["providerType"] = componentType + newSubComponent["parentId"] = component["id"] + # Create sub component + component_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm) + open_url(component_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newSubComponent)) + # Check if users and groups synchronization is needed + if component["providerType"] == "org.keycloak.storage.UserStorageProvider" and syncLdapMappers != "no": + # Get subcomponent + subComponent = self.get_component_by_name_provider_and_parent( + name=newSubComponent["name"], + provider_type=newSubComponent["providerType"], + provider_id=newSubComponent["providerId"], + parent_id=component["id"], + realm=realm) + if subComponent != {}: + # Sync sub component + sync_url = URL_USER_STORAGE_MAPPER_SYNC.format( + url=self.baseurl, + realm=realm, + parentid=subComponent["parentId"], + id=subComponent["id"], + direction=syncLdapMappers) + open_url(sync_url, + method='POST', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not create sub components for parent %s in realm %s: %s' + % (component["name"], realm, str(e))) + + def update_component(self, newComponent, realm='master'): + """ + Update a component. + :param component: Representation of the component tu update. + :param realm: Realm + :return: new representation of the updated component + """ + try: + # Add existing component Id to new component + component_url = URL_COMPONENT.format( + url=self.baseurl, + realm=realm, + id=newComponent["id"]) + open_url(component_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newComponent)) + return self.get_component_by_id(newComponent['id'], realm=realm) + except Exception as e: + self.module.fail_json(msg='Could not update component %s in realm %s: %s' + % (newComponent["name"], realm, str(e))) + + def update_sub_components(self, component, newSubComponents, syncLdapMappers, realm='master'): + try: + changed = False + # Get all existing sub components for the component to update. + subComponents = self.get_all_sub_components(parent_id=component["id"], + realm=realm) + # For all new sub components to update + for componentType in newSubComponents.keys(): + for newSubComponent in newSubComponents[componentType]: + newSubComponent["providerType"] = componentType + # Search in al existing subcomponents + newSubComponentFound = False + for subComponent in subComponents: + # If the existing component is the same type than the new component + if subComponent["name"] == newSubComponent["name"]: + # Compare them to see if the existing component need to be changed + if not isDictEquals(newSubComponent, subComponent): + # Update the Ids + newSubComponent["parentId"] = subComponent["parentId"] + newSubComponent["id"] = subComponent["id"] + # Update the sub component + component_url = URL_COMPONENT.format(url=self.baseurl, + realm=realm, + id=subComponent["id"]) + open_url(component_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newSubComponent)) + changed = True + newSubComponentFound = True + # If sync is needed for the subcomponent + if component["providerType"] == "org.keycloak.storage.UserStorageProvider" and syncLdapMappers != "no": + # Do the sync + sync_url = URL_USER_STORAGE_MAPPER_SYNC.format( + url=self.baseurl, + realm=realm, + parentid=subComponent["parentId"], + id=subComponent["id"], + direction=syncLdapMappers) + open_url(sync_url, + method='POST', + headers=self.restheaders) + break + # If sub-component does not already exists + if not newSubComponentFound: + # Update the parent Id + newSubComponent["parentId"] = component["id"] + # Create the sub-component + component_url = URL_COMPONENTS.format(url=self.baseurl, + realm=realm) + open_url(component_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newSubComponent)) + changed = True + # Sync LDAP for group mappers + if component["providerType"] == "org.keycloak.storage.UserStorageProvider" and syncLdapMappers != "no": + # Get subcomponents + subComponents = self.get_component_by_name_provider_and_parent( + name=newSubComponent["name"], + provider_type=newSubComponent["providerType"], + parent_id=component["id"], + realm=realm) + for subComponent in subComponents: + sync_url = URL_USER_STORAGE_MAPPER_SYNC.format( + url=self.baseurl, + realm=realm, + parentid=subComponent["parentId"], + id=subComponent["id"], + direction=syncLdapMappers) + open_url(sync_url, + method='POST', + headers=self.restheaders) + return changed + except Exception as e: + self.module.fail_json(msg='Could not update component %s in realm %s: %s' + % (component["name"], realm, str(e))) + + def get_all_sub_components(self, parent_id, realm='master'): + """ + Get a list of sub components representation for a parent component. + :param parent_ip: ID of the parent component + :param realm: Realm + :return: List of representation for the sub component. + """ + try: + subcomponents_url = URL_SUB_COMPONENTS.format( + url=self.baseurl, + realm=realm, + parent=parent_id) + subcomponents = json.load( + open_url( + subcomponents_url, + method='GET', + headers=self.restheaders)) + return subcomponents + except Exception as e: + self.module.fail_json(msg='Could not get sub components for parent component %s in realm %s: %s' + % (parent_id, realm, str(e))) + + def delete_component(self, component_id, realm='master'): + """ + Delete component from Keycloak server + :param component_id: Id of the component to delete + :param realm: Realm + :return: HTTP response + """ + try: + component_url = URL_COMPONENT.format( + url=self.baseurl, + realm=realm, + id=component_id) + return open_url( + component_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete component %s in realm %s: %s' + % (component_id, realm, str(e))) + + def sync_user_storage(self, component_id, action, realm='master'): + """ + Synchronize LDAP user storage component with Keycloak. + :param component_id: ID of the component to synchronize + :param action: Type of synchronization ("triggerFullSync" or "triggerChangedUsersSync") + :param realm: Realm + :return: HTTP response + """ + try: + sync_url = URL_USER_STORAGE_SYNC.format( + url=self.baseurl, + realm=realm, + id=component_id, + action=action) + return open_url(sync_url, + method='POST', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not synchronize component %s action %s in realm %s: %s' + % (component_id, action, realm, str(e))) + + def add_idp_endpoints(self, idPConfiguration, url): + """ + This function extract OpenID connect endpoints from the identity provider's + openid-configuration URL. + Endpoints are added to the idp configuration object received in parameter. + :param idPConfiguration: Identity provider configuration dict to update. + :param url: Identity provider's openid-configuration URL. + :param realm: Realm + :return: Nothing + """ + openIdConfig = {} + try: + if url is not None: + openIdConfig = json.load(open_url(url, method='GET')) + if 'userinfo_endpoint' in openIdConfig.keys(): + idPConfiguration["userInfoUrl"] = openIdConfig["userinfo_endpoint"] + if 'token_endpoint' in openIdConfig.keys(): + idPConfiguration["tokenUrl"] = openIdConfig["token_endpoint"] + if 'jwks_uri' in openIdConfig.keys(): + idPConfiguration["jwksUrl"] = openIdConfig["jwks_uri"] + if 'issuer' in openIdConfig.keys(): + idPConfiguration["issuer"] = openIdConfig["issuer"] + if 'authorization_endpoint' in openIdConfig.keys(): + idPConfiguration["authorizationUrl"] = openIdConfig["authorization_endpoint"] + if 'end_session_endpoint' in openIdConfig.keys(): + idPConfiguration["logoutUrl"] = openIdConfig["end_session_endpoint"] + return openIdConfig + except Exception as e: + self.module.fail_json(msg='Could not get IdP configuration from endpoint %s: %s' + % (url, str(e))) + + def delete_all_idp_mappers(self, alias, realm='master'): + """ + Delete all mappers for an identity provider + :param alias: Idp alias to delete mappers from. + :param realm: Realm + :return: True if mappers have been deleted, False otherwise. + """ + changed = False + try: + # Get idp's mappers list + mappers_url = URL_IDP_MAPPERS.format( + url=self.baseurl, + realm=realm, + alias=alias) + mappers = json.load( + open_url( + mappers_url, + method='GET', + headers=self.restheaders)) + for mapper in mappers: + mapper_url = URL_IDP_MAPPER.format( + url=self.baseurl, + realm=realm, + alias=alias, + id=mapper['id']) + open_url(mapper_url, + method='DELETE', + headers=self.restheaders) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not delete mappers for IdP %s in realm %s: %s' + % (alias, realm, str(e))) + + def create_or_update_idp_mappers(self, alias, idPMappers, realm='master'): + """ + Create, update or delete mappers for an identity provider. + :param alias: Idp alias to add, update or delete mappers from. + :param idPMappers: List of mappers + :param realm: Realm + :return: True if mappers have been modified, False otherwise. + """ + changed = False + try: + # Get idp's mappers list + mappers_url = URL_IDP_MAPPERS.format( + url=self.baseurl, + realm=realm, + alias=alias) + mappers = json.load( + open_url( + mappers_url, + method='GET', + headers=self.restheaders)) + for idPMapper in idPMappers: + desiredState = "present" + if "state" in idPMapper: + desiredState = idPMapper["state"] + del(idPMapper["state"]) + mapperFound = False + for mapper in mappers: + if mapper['name'] == idPMapper['name']: + mapperFound = True + break + # If mapper already exist and is different + if mapperFound and not isDictEquals(idPMapper, mapper): + # update the existing mapper + for key in idPMapper.keys(): + mapper[key] = idPMapper[key] + mapper_url = URL_IDP_MAPPER.format( + url=self.baseurl, + realm=realm, + alias=alias, + id=mapper['id']) + open_url(mapper_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(mapper)) + changed = True + elif mapperFound and desiredState == "absent": + # delete the mapper + mapper_url = URL_IDP_MAPPER.format( + url=self.baseurl, + realm=realm, + alias=alias, + id=mapper['id']) + open_url(mapper_url, + method='DELETE', + headers=self.restheaders) + changed = True + # If the mapper does not already exist + elif not mapperFound and desiredState != "absent": + # Complete the mapper settings with defaults + if 'identityProviderMapper' not in idPMapper.keys(): # if mapper's type is provided + idPMapper['identityProviderMapper'] = 'oidc-user-attribute-idp-mapper' + idPMapper['identityProviderAlias'] = alias + # Create it + mappers_url = URL_IDP_MAPPERS.format( + url=self.baseurl, + realm=realm, + alias=alias) + open_url(mappers_url, + method='POST', + headers=self.restheaders, + data=json.dumps(idPMapper)) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not create or update mappers for IdP %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_idp_by_alias(self, alias, realm='master'): + """ + Get an identity provider by alias. Representation of the idp is returned. + :param alias: Alias of the Idp to get + :param realm: Realm + :return: Representation of the Idp. + """ + try: + idPRepresentation = {} + idp_url = URL_IDP.format( + url=self.baseurl, + realm=realm, + alias=alias) + # Get all IdP from Keycloak server + idPRepresentation = json.load( + open_url(idp_url, + method='GET', + headers=self.restheaders)) + return idPRepresentation + except Exception as e: + self.module.fail_json(msg='Could not get IdP by alias %s in realm %s: %s' + % (alias, realm, str(e))) + + def search_idp_by_alias(self, alias, realm='master'): + """ + Search an identity provider by alias. Representation of the idp found is returned. + :param alias: Alias of the Idp to find + :param realm: Realm + :return: Representation of the Idp. An empty dict is returned if alias is not found. + """ + try: + idPRepresentation = {} + idps_url = URL_IDPS.format( + url=self.baseurl, + realm=realm) + # Get all idps from Keycloak server + listIdPs = json.load( + open_url( + idps_url, + method='GET', + headers=self.restheaders)) + for idP in listIdPs: + if idP['alias'] == alias: + # Get existing IdP + idPRepresentation = idP + break + return idPRepresentation + except Exception as e: + self.module.fail_json(msg='Could not search IdP by alias %s in realm %s: %s' + % (alias, realm, str(e))) + + def search_idp_by_client_id(self, client_id, realm='master'): + """ + Search an identity provider by its client ID. Representation of the idp found is returned. + :param alias: Alias of the Idp to find + :param realm: Realm + :return: Representation of the Idp. An empty dict is returned if client id is not found. + """ + try: + idPRepresentation = {} + idps_url = URL_IDPS.format( + url=self.baseurl, + realm=realm) + # Get all idps from Keycloak server + listIdPs = json.load( + open_url(idps_url, + method='GET', + headers=self.restheaders)) + for idP in listIdPs: + if 'config' in idP and idP['config'] is not None and 'clientId' in idP['config'] and idP['config']['clientId'] == client_id: + # Obtenir le IdP exitant + idPRepresentation = idP + break + return idPRepresentation + except Exception as e: + self.module.fail_json(msg='Could not search IdP by client Id %s in realm %s: %s' + % (client_id, realm, str(e))) + + def create_idp(self, newIdPRepresentation, realm='master'): + """ + Create an identity provider on a Keycloak server. + :param newIdPRepresentation: Representation of the Idp to create. + :param realm: Realm + :return: Actual representation of the idp created. + """ + try: + idps_url = URL_IDPS.format( + url=self.baseurl, + realm=realm) + open_url(idps_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newIdPRepresentation)) + return self.get_idp_by_alias(newIdPRepresentation["alias"], realm) + except Exception as e: + self.module.fail_json(msg='Could not create the IdP %s in realm %s: %s' + % (newIdPRepresentation["alias"], realm, str(e))) + + def update_idp(self, newIdPRepresentation, realm='master'): + """ + Update an identity provider on a Keycloak server. + :param newIdPRepresentation: Representation of the Idp to create. + :param realm: Realm + :return: Actual representation of the updated idp. + """ + try: + idp_url = URL_IDP.format( + url=self.baseurl, + realm=realm, + alias=newIdPRepresentation["alias"]) + open_url( + idp_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newIdPRepresentation)) + return self.get_idp_by_alias(newIdPRepresentation["alias"], realm) + except Exception as e: + self.module.fail_json(msg='Could not update the IdP %s in realm %s: %s' + % (newIdPRepresentation["alias"], realm, str(e))) + + def delete_idp(self, alias, realm='master'): + """ + Delete an identity provider from a Keycloak server. + :param alias: Alias of the IdP to delete + :param realm: Realm + :return: HTTP response + """ + try: + idp_url = URL_IDP.format( + url=self.baseurl, + realm=realm, + alias=alias) + return open_url( + idp_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete the IdP %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_idp_mappers(self, alias, realm='master'): + """ + Get all mappers for an identity provider. + :param alias: Alias of the Idp + :param realm: Realm + :return: List of mappers + """ + try: + mapper_url = URL_IDP_MAPPERS.format( + url=self.baseurl, + realm=realm, + alias=alias) + # Get all mappers for the IdP from Keycloak server + idMappers = json.load( + open_url( + mapper_url, + method='GET', + headers=self.restheaders)) + return idMappers + except Exception as e: + self.module.fail_json(msg='Could not get IdP mappers for alias %s in realm %s: %s' + % (alias, realm, str(e))) + + def search_realm(self, realm): + """ + Search a realm by its name. + :param realm: Name of the realm to find + :return: Realm representation. An empty dict is returned when realm have not been found + """ + try: + realmRepresentation = {} + realms_url = URL_REALMS.format(url=self.baseurl) + listRealms = json.load( + open_url( + realms_url, + method='GET', + headers=self.restheaders)) + for theRealm in listRealms: + if theRealm['realm'] == realm: + realmRepresentation = theRealm + break + return realmRepresentation + except Exception as e: + self.module.fail_json(msg='Could not search for realm %s: %s' + % (realm, str(e))) + + def get_realm(self, realm): + """ + Get a Realm. + :param realm: realm to get + :return: Representation of the realm. + """ + try: + realm_url = URL_REALM.format( + url=self.baseurl, + realm=realm) + realmRepresentation = json.load( + open_url(realm_url, + method='GET', + headers=self.restheaders)) + return realmRepresentation + except Exception as e: + self.module.fail_json(msg='Could get realm %s: %s' + % (realm, str(e))) + + def create_realm(self, newRealmRepresentation): + """ + Create a new Realm. + :param newRealmRepresentation: Representation of the realm to create + :return: Representation of the realm created. + """ + try: + realms_url = URL_REALMS.format(url=self.baseurl) + open_url( + realms_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newRealmRepresentation)) + realmRepresentation = self.get_realm(realm=newRealmRepresentation["realm"]) + return realmRepresentation + except Exception as e: + self.module.fail_json(msg='Could not create realm %s: %s' + % (newRealmRepresentation["realm"], str(e))) + + def delete_realm(self, realm): + """ + Delete a Realm. + :param realm: realm to delete + :return: HTTP Response. + """ + try: + realm_url = URL_REALM.format( + url=self.baseurl, + realm=realm) + return open_url( + realm_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could delete realm %s: %s' + % (realm, str(e))) + + def update_realm(self, newRealmRepresentation): + """ + Update a Realm. + :param newRealmRepresentation: Updated configuration of the realm + :return: Realm representation + """ + try: + realm_url = URL_REALM.format( + url=self.baseurl, + realm=newRealmRepresentation["realm"]) + open_url( + realm_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newRealmRepresentation)) + return self.get_realm(newRealmRepresentation["realm"]) + except Exception as e: + self.module.fail_json(msg='Could update realm %s: %s' + % (newRealmRepresentation["realm"], str(e))) + + def update_realm_events_config(self, realm, newEventsConfig): + """ + Update events configuration for a REALM. + :param realm: Realm to update + :param eventConfig: new event configuration + :return: Updated events configuration for the realm. + """ + try: + realm_events_config_url = URL_REALM_EVENT_CONFIG.format( + url=self.baseurl, + realm=realm) + open_url( + realm_events_config_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newEventsConfig)) + return self.get_realm_events_config(realm=realm) + except Exception as e: + self.module.fail_json(msg='Could not update events config for realm %s: %s' + % (realm, str(e))) + + def get_realm_events_config(self, realm): + """ + Get events configuration for a REALM. + :param realm: Realm to update + :return: Updated events configuration for the realm. + """ + try: + realm_events_config_url = URL_REALM_EVENT_CONFIG.format( + url=self.baseurl, + realm=realm) + return json.load( + open_url( + realm_events_config_url, + method='GET', + headers=self.restheaders)) + except Exception as e: + self.module.fail_json(msg='Could not get events config for realm %s: %s' + % (realm, str(e))) + + def search_realm_role_by_name(self, name, realm="master"): + """ + Search a REALM role by its name. + :param name: Name of the realm role to find + :param realm: Realm + :return: Realm role representation. An empty dict is returned when role have not been found + """ + try: + rolerep = {} + listRoles = self.get_realm_roles(realm=realm) + for role in listRoles: + if role['name'] == name: + rolerep = role + break + return rolerep + except Exception as e: + self.module.fail_json(msg='Could not search for role % in realm %s: %s' + % (name, realm, str(e))) + + def get_realm_roles(self, realm="master"): + """ + Get all REALM roles. + :param realm: Realm + :return: Realm roles representation. + """ + try: + realm_roles_url = URL_REALM_ROLES.format( + url=self.baseurl, + realm=realm) + listRoles = json.load(open_url(realm_roles_url, + method='GET', + headers=self.restheaders)) + return listRoles + except Exception as e: + self.module.fail_json(msg='Could not get roles in realm %s: %s' + % (realm, str(e))) + + def get_realm_role(self, name, realm="master"): + """ + Get a REALM role. + :param name: Name of the realm role to create + :param realm: Realm + :return: Realm roles representation. + """ + try: + realm_role_url = URL_REALM_ROLE.format( + url=self.baseurl, + realm=realm, + name=name) + role = json.load( + open_url( + realm_role_url, + method='GET', + headers=self.restheaders)) + return role + except Exception as e: + self.module.fail_json(msg='Could not get role %s in realm %s: %s' + % (name, realm, str(e))) + + def create_realm_role(self, newRoleRepresentation, realm='master'): + """ + Create a new Realm role. + :param newRoleRepresentation: Representation of the realm role to create + :param realm: Realm + :return: Representation of the realm role created. + """ + try: + realm_roles_url = URL_REALM_ROLES.format( + url=self.baseurl, + realm=realm) + open_url( + realm_roles_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newRoleRepresentation)) + roleRepresentation = self.get_realm_role( + name=newRoleRepresentation['name'], + realm=realm) + return roleRepresentation + except Exception as e: + self.module.fail_json(msg='Could not create realm role %s in realm %s: %s' + % (newRoleRepresentation["name"], realm, str(e))) + + def delete_realm_role(self, name, realm='master'): + """ + Delete a Realm role. + :param name: Name of the realm role to delete + :param realm: Realm + :return: Representation of the realm role created. + """ + try: + realm_role_url = URL_REALM_ROLE.format( + url=self.baseurl, + realm=realm, + name=name) + return open_url( + realm_role_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete realm role %s in realm %s: %s' + % (name, realm, str(e))) + + def update_realm_role(self, newRoleRepresentation, realm='master'): + """ + Update a Realm role. + :param newRoleRepresentation: Representation of the realm role to update + :param realm: Realm + :return: Representation of the updated realm role. + """ + try: + realm_role_url = URL_REALM_ROLE.format( + url=self.baseurl, + realm=realm, + name=newRoleRepresentation["name"]) + open_url( + realm_role_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newRoleRepresentation)) + roleRepresentation = self.get_realm_role( + name=newRoleRepresentation['name'], + realm=realm) + return roleRepresentation + except Exception as e: + self.module.fail_json(msg='Could not update realm role %s in realm %s: %s' + % (newRoleRepresentation["name"], realm, str(e))) + + def get_realm_role_composites_with_client_id(self, name, realm='master'): + """ + Get realm role's composites with their clientId + :param name: Name of the role to get the composites from + :param realm: Realm + :return: Representation of the realm role's composites including the clientIds. + """ + composites = self.get_realm_role_composites( + name=name, + realm=realm) + for composite in composites: + if composite["clientRole"]: + composite["clientId"] = self.get_client_by_id( + id=composite["containerId"], + realm=realm)["clientId"] + return composites + + def get_realm_role_composites(self, name, realm='master'): + """ + Get realm role's composites + :param name: Name of the role to get the composites from + :param realm: Realm + :return: Representation of the realm role's composites. + """ + try: + realm_role_composites_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, + realm=realm, + name=name) + composites = json.load( + open_url( + realm_role_composites_url, + method='GET', + headers=self.restheaders)) + return composites + except Exception as e: + self.module.fail_json(msg='Could not get realm role %s composites in realm %s: %s' + % (name, realm, str(e))) + + def create_realm_role_composites(self, newCompositesToCreate, name, realm='master'): + """ + Create composites for a Realm role. + :param newCompositesToCreate: Representation of the realm role's composites to create. + :param name: Name of the realm role + :param realm: Realm + :return: HTTP Response + """ + try: + realm_role_composites_url = URL_REALM_ROLE_COMPOSITES.format( + url=self.baseurl, + realm=realm, + name=name) + return open_url( + realm_role_composites_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newCompositesToCreate)) + except Exception as e: + self.module.fail_json(msg='Could not create realm role %s composites in realm %s: %s' + % (name, realm, str(e))) + + def create_or_update_realm_role_composites(self, newComposites, newRoleRepresentation, realm='master'): + """ + Create or update composite roles for a REALM role + :param newComposites: Representation of the composites to update or create + :param newRoleRepresentation: Representation of the realm role to update composites. + :param realm: Realm + :return: True if composites have changed, False otherwise. + """ + changed = False + newCompositesToCreate = [] + try: + # Get the realm role's composites already present on the Keycloak server + existingComposites = self.get_realm_role_composites( + name=newRoleRepresentation["name"], + realm=realm) + if existingComposites is None: + existingComposites = [] + for existingComposite in existingComposites: + newCompositesToCreate.append(existingComposite) + # If new version of the role is composite and there are composites in it + if newComposites is not None and newRoleRepresentation["composite"]: + for newComposite in newComposites: + newCompositeFound = False + # Search composite to assing in composites of the role on the Keycloak Server + for composite in existingComposites: + if composite["clientRole"] and "clientId" in newComposite: # If composite is a client role + # Get the clientId + client = self.get_client_by_id( + id=composite["containerId"], + realm=realm) + clientId = client["clientId"] + if composite["name"] == newComposite["name"] and clientId == newComposite["clientId"]: + newCompositeFound = True + break + elif composite["name"] == newComposite["name"]: + newCompositeFound = True + break + # If role is not already assigned to the role + if not newCompositeFound: + roles = [] + # If composite is a client role + if "clientId" in newComposite: + # Get the client + client = self.get_client_by_clientid( + client_id=newComposite["clientId"], + realm=realm) + if client != {}: + # Get client's roles + roles = self.get_client_roles( + client_id=client["id"], + realm=realm) + else: # It is a REALM role + # Get all realm roles + roles = self.get_realm_roles(realm=realm) + # Search in all roles found which is the role to assigne + for role in roles: + # Id role is found + if role["name"] == newComposite["name"]: + newCompositesToCreate.append(role) + changed = True + if changed: + self.create_realm_role_composites( + newCompositesToCreate=newCompositesToCreate, + name=newRoleRepresentation["name"], + realm=realm) + return changed + except Exception as e: + self.module.fail_json(msg='Could not create or update realm role %s composites in realm %s: %s' + % (newRoleRepresentation["name"], realm, str(e))) + + def get_user_by_id(self, user_id, realm='master'): + """ + Get a User by its ID. + :param user_id: ID of the user. + :param realm: Realm + :return: Representation of the user. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + userRepresentation = json.load( + open_url( + user_url, + method='GET', + headers=self.restheaders)) + return userRepresentation + except Exception as e: + self.module.fail_json(msg='Could not get user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def search_user_by_username(self, username, realm="master"): + """ + Search a user by its username. + :param username: User name to find + :return: User representation. An empty dict is returned when role have not been found + """ + try: + userrep = {} + url_search_user_by_username = URL_USERS.format( + url=self.baseurl, + realm=realm) + '?username=' + username + listUsers = json.load( + open_url( + url_search_user_by_username, + method='GET', + headers=self.restheaders)) + for user in listUsers: + if user['username'] == username: + userrep = user + break + return userrep + except Exception as e: + self.module.fail_json(msg='Could not search for user %s in realm %s: %s' + % (username, realm, str(e))) + + def create_user(self, newUserRepresentation, realm='master'): + """ + Create a new User. + :param newUserRepresentation: Representation of the user to create + :param realm: Realm + :return: Representation of the user created. + """ + try: + users_url = URL_USERS.format( + url=self.baseurl, + realm=realm) + open_url(users_url, + method='POST', + headers=self.restheaders, + data=json.dumps(newUserRepresentation)) + userRepresentation = self.search_user_by_username( + username=newUserRepresentation['username'], + realm=realm) + return userRepresentation + except Exception as e: + self.module.fail_json(msg='Could not create user %s in realm %s: %s' + % (newUserRepresentation['username'], realm, str(e))) + + def update_user(self, newUserRepresentation, realm='master'): + """ + Update a User. + :param newUserRepresentation: Representation of the user to update. This representation must include the ID of the user. + :param realm: Realm + :return: Representation of the updated user. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=newUserRepresentation["id"]) + open_url( + user_url, + method='PUT', + headers=self.restheaders, + data=json.dumps(newUserRepresentation)) + userRepresentation = self.get_user_by_id( + user_id=newUserRepresentation['id'], + realm=realm) + return userRepresentation + except Exception as e: + self.module.fail_json(msg='Could not update user %s in realm %s: %s' + % (newUserRepresentation['username'], realm, str(e))) + + def delete_user(self, user_id, realm='master'): + """ + Delete a User. + :param user_id: ID of the user to be deleted + :param realm: Realm + :return: HTTP response. + """ + try: + user_url = URL_USER.format( + url=self.baseurl, + realm=realm, + id=user_id) + return open_url( + user_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def get_user_realm_roles(self, user_id, realm='master'): + """ + Get realm roles for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the realm roles. + """ + try: + role_mappings_url = URL_USER_ROLE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=user_id) + role_mappings = json.load( + open_url( + role_mappings_url, + method='GET', + headers=self.restheaders)) + realmRoles = [] + for roleMapping in role_mappings["realmMappings"]: + realmRoles.append(roleMapping["name"]) + return realmRoles + except Exception as e: + self.module.fail_json(msg='Could not get role mappings for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def update_user_realm_roles(self, user_id, realmRolesRepresentation, realm='master'): + """ + Update realm roles for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the realm roles. + """ + try: + role_mappings_url = URL_USER_REALM_ROLE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=user_id) + return open_url( + role_mappings_url, + method='POST', + headers=self.restheaders, + data=json.dumps(realmRolesRepresentation)) + except Exception as e: + self.module.fail_json(msg='Could not update realm role mappings for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def get_user_client_roles(self, user_id, realm='master'): + """ + Get client roles for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the client roles. + """ + try: + clientRoles = [] + role_mappings_url = URL_USER_ROLE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=user_id) + userMappings = json.load( + open_url( + role_mappings_url, + method='GET', + headers=self.restheaders)) + for clientMapping in userMappings["clientMappings"].keys(): + clientRole = {} + clientRole["clientId"] = userMappings["clientMappings"][clientMapping]["client"] + roles = [] + for role in userMappings["clientMappings"][clientMapping]["mappings"]: + roles.append(role["name"]) + clientRole["roles"] = roles + clientRoles.append(clientRole) + return clientRoles + except Exception as e: + self.module.fail_json(msg='Could not get role mappings for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def delete_user_client_roles(self, user_id, client_id, realm='master'): + """ + Delete client roles for a user. + :param user_id: User ID + :param client_id: Client ID for client roles to delete. + :param realm: Realm + :return: HTTP Response. + """ + try: + role_mappings_url = URL_USER_CLIENT_ROLE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=user_id, + client_id=client_id) + return open_url( + role_mappings_url, + method='DELETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not delete client role mappings for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def create_user_client_roles(self, user_id, client_id, rolesToAssing, realm='master'): + """ + Delete client roles for a user. + :param user_id: User ID + :param client_id: Client ID for client roles to create. + :param rolesToAssing: Representation of the client roles to create. + :param realm: Realm + :return: HTTP Response. + """ + try: + role_mappings_url = URL_USER_CLIENT_ROLE_MAPPINGS.format( + url=self.baseurl, + realm=realm, + id=user_id, + client_id=client_id) + return open_url( + role_mappings_url, + method='POST', + headers=self.restheaders, + data=json.dumps(rolesToAssing)) + except Exception as e: + self.module.fail_json(msg='Could not create client role mappings for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def get_user_groups(self, user_id, realm='master'): + """ + Get groups for a user. + :param user_id: User ID + :param realm: Realm + :return: Representation of the client groups. + """ + try: + groups = [] + user_groups_url = URL_USER_GROUPS.format( + url=self.baseurl, + realm=realm, + id=user_id) + userGroups = json.load( + open_url( + user_groups_url, + method='GET', + headers=self.restheaders)) + for userGroup in userGroups: + groups.append(userGroup["name"]) + return groups + except Exception as e: + self.module.fail_json(msg='Could not get groups for user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def add_user_in_group(self, user_id, group_id, realm='master'): + """ + Add a user to a group. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP Response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='PUT', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not add user %s in group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def remove_user_from_group(self, user_id, group_id, realm='master'): + """ + Remove a user from a group for a user. + :param user_id: User ID + :param group_id: Group Id to add the user to. + :param realm: Realm + :return: HTTP response + """ + try: + user_group_url = URL_USER_GROUP.format( + url=self.baseurl, + realm=realm, + id=user_id, + group_id=group_id) + return open_url( + user_group_url, + method='DETETE', + headers=self.restheaders) + except Exception as e: + self.module.fail_json(msg='Could not remove user %s from group %s in realm %s: %s' + % (user_id, group_id, realm, str(e))) + + def assing_roles_to_user(self, user_id, userRealmRoles, userClientRoles, realm='master'): + """ + Assign roles to a user. + :param user_id: user ID to whom assign roles. + :param userRealmRoles: Realm roles to assign to user. + :param userClientRoles: Client roles to assign to user. + :param realm: Realm + :return: True is user's role have changed, False otherwise. + """ + try: + # Get the new created user realm roles + newUserRealmRoles = self.get_user_realm_roles( + user_id=user_id, + realm=realm) + # Get the new created user client roles + newUserClientRoles = self.get_user_client_roles( + user_id=user_id, + realm=realm) + changed = False + # Assign Realm Roles + realmRolesRepresentation = [] + # Get all realm roles + allRealmRoles = self.get_realm_roles(realm=realm) + for realmRole in userRealmRoles: + # Look for existing role into user representation + if realmRole not in newUserRealmRoles: + roleid = None + # Find the role id + for role in allRealmRoles: + if role["name"] == realmRole: + roleid = role["id"] + break + if roleid is not None: + realmRoleRepresentation = {} + realmRoleRepresentation["id"] = roleid + realmRoleRepresentation["name"] = realmRole + realmRolesRepresentation.append(realmRoleRepresentation) + if len(realmRolesRepresentation) > 0: + # Assign Role + self.update_user_realm_roles( + user_id=user_id, + realmRolesRepresentation=realmRolesRepresentation, + realm=realm) + changed = True + # Assign clients roles if they need changes + if len(userClientRoles) > 0 and not isDictEquals(userClientRoles, newUserClientRoles): + for clientToAssingRole in userClientRoles: + # Get the client roles + client_id = self.get_client_by_clientid( + client_id=clientToAssingRole["clientId"], + realm=realm)['id'] + clientRoles = self.get_client_roles( + client_id=client_id, + realm=realm) + if clientRoles != {}: + rolesToAssing = [] + for roleToAssing in clientToAssingRole["roles"]: + newRole = {} + # Find his Id + for clientRole in clientRoles: + if clientRole["name"] == roleToAssing: + newRole["id"] = clientRole["id"] + newRole["name"] = roleToAssing + rolesToAssing.append(newRole) + if len(rolesToAssing) > 0: + # Delete exiting client Roles + self.delete_user_client_roles( + user_id=user_id, + client_id=client_id, + realm=realm) + # Assign Role + self.create_user_client_roles( + user_id=user_id, + client_id=client_id, + rolesToAssing=rolesToAssing, + realm=realm) + changed = True + return changed + except Exception as e: + self.module.fail_json(msg='Could not assign roles to user %s in realm %s: %s' + % (user_id, realm, str(e))) + + def update_user_groups_membership(self, newUserRepresentation, realm='master'): + """ + Update user's group membership + :param newUserRepresentation: Representation of the user. This representation must include the ID. + :param realm: Realm + :return: True if group membership has been changed. False Otherwise. + """ + changed = False + try: + newUserGroups = self.get_user_groups( + user_id=newUserRepresentation['id'], + realm=realm) + # If group membership need to be changed + if not isDictEquals(newUserRepresentation["groups"], newUserGroups): + # Set user groups + if "groups" in newUserRepresentation and newUserRepresentation['groups'] is not None: + for userGroups in newUserRepresentation["groups"]: + # Get groups Available + groups = self.get_groups(realm=realm) + for group in groups: + if "name" in group and group["name"] == userGroups: + self.add_user_in_group( + user_id=newUserRepresentation["id"], + group_id=group["id"], + realm=realm) + changed = True + return changed + except Exception as e: + raise e diff --git a/lib/ansible/modules/identity/keycloak/keycloak_authentication.py b/lib/ansible/modules/identity/keycloak/keycloak_authentication.py new file mode 100644 index 00000000000000..0e51c9bfbb1a73 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_authentication.py @@ -0,0 +1,227 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, INSPQ +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: keycloak_authentication +short_description: Configure authentication in Keycloak +description: + - This module actually can only make a copy of an existing authentication flow, add an execution to it and configure it. + - It can also delete the flow. +version_added: "2.9" +options: + realm: + description: + - The name of the realm in which is the authentication. + required: true + alias: + description: + - Alias for the authentication flow + required: true + providerId: + description: + - providerId for the new flow when not copied from an existing flow. + required: false + copyFrom: + description: + - flowAlias of the authentication flow to use for the copy. + required: false + authenticationExecutions: + description: + - Configuration structure for the executions + required: false + state: + description: + - Control if the authentication flow must exists or not + choices: [ "present", "absent" ] + default: present + required: false + force: + type: bool + default: false + description: + - If true, allows to remove the authentication flow and recreate it. + required: false +extends_documentation_fragment: + - keycloak + +author: + - Philippe Gauthier (@elfelip) +''' + +EXAMPLES = ''' + - name: Create an authentication flow from first broker login and add an execution to it. + keycloak_authentication: + auth_keycloak_url: http://localhost:8080/auth + auth_sername: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + copyFrom: "first broker login" + authenticationExecutions: + - providerId: "test-execution1" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.execution1.property" + config: + test1.property: "value" + - providerId: "test-execution2" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.execution2.property" + config: + test2.property: "value" + state: present + + - name: Re-create the authentication flow + keycloak_authentication: + auth_keycloak_url: http://localhost:8080/auth + auth_sername: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + copyFrom: "first broker login" + authenticationExecutions: + - providerId: "test-provisioning" + requirement: "REQUIRED" + authenticationConfig: + alias: "test.provisioning.property" + config: + test.provisioning.property: "value" + state: present + force: yes + + - name: Remove authentication. + keycloak_authentication: + auth_keycloak_url: http://localhost:8080/auth + auth_sername: admin + auth_password: password + realm: master + alias: "Copy of first broker login" + state: absent +''' + +RETURN = ''' +flow: + description: JSON representation for the authentication. + returned: on success + type: dict +msg: + description: Error message if it is the case + returned: on error + type: str +changed: + description: Return True if the operation changed the authentication on the keycloak server, false otherwise. + returned: always + type: bool +''' +from ansible.module_utils.keycloak import KeycloakAPI, keycloak_argument_spec +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + :returm: + """ + argument_spec = keycloak_argument_spec() + meta_args = dict( + realm=dict(type='str', required=True), + alias=dict(type='str', required=True), + providerId=dict(type='str'), + copyFrom=dict(type='str'), + authenticationExecutions=dict(type='list'), + state=dict(choices=["absent", "present"], default='present'), + force=dict(type='bool', default=False), + ) + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + result = dict(changed=False, msg='', flow={}) + kc = KeycloakAPI(module) + + realm = module.params.get('realm') + state = module.params.get('state') + force = module.params.get('force') + + newAuthenticationRepresentation = {} + newAuthenticationRepresentation["alias"] = module.params.get("alias") + newAuthenticationRepresentation["copyFrom"] = module.params.get("copyFrom") + newAuthenticationRepresentation["providerId"] = module.params.get("providerId") + newAuthenticationRepresentation["authenticationExecutions"] = module.params.get("authenticationExecutions") + + changed = False + + authenticationRepresentation = kc.get_authentication_flow_by_alias(alias=newAuthenticationRepresentation["alias"], realm=realm) + + if authenticationRepresentation == {}: # Authentication flow does not exist + if (state == 'present'): # If desired state is prenset + # If copyFrom is defined, create authentication flow from a copy + if "copyFrom" in newAuthenticationRepresentation and newAuthenticationRepresentation["copyFrom"] is not None: + authenticationRepresentation = kc.copy_auth_flow(config=newAuthenticationRepresentation, realm=realm) + else: # Create an empty authentication flow + authenticationRepresentation = kc.create_empty_auth_flow(config=newAuthenticationRepresentation, realm=realm) + # If the authentication still not exist on the server, raise an exception. + if authenticationRepresentation is None: + result['msg'] = "Authentication just created not found: " + str(newAuthenticationRepresentation) + module.fail_json(**result) + # Configure the executions for the flow + kc.create_or_update_executions(config=newAuthenticationRepresentation, realm=realm) + changed = True + # Get executions created + executionsRepresentation = kc.get_executions_representation(config=newAuthenticationRepresentation, realm=realm) + if executionsRepresentation is not None: + authenticationRepresentation["authenticationExecutions"] = executionsRepresentation + result['changed'] = changed + result['flow'] = authenticationRepresentation + elif state == 'absent': # If desired state is absent. + result['msg'] = newAuthenticationRepresentation["alias"] + ' absent' + else: # The authentication flow already exist + if (state == 'present'): # if desired state is present + if force: # If force option is true + # Delete the actual authentication flow + kc.delete_authentication_flow_by_id(id=authenticationRepresentation["id"], realm=realm) + changed = True + # If copyFrom is defined, create authentication flow from a copy + if "copyFrom" in newAuthenticationRepresentation and newAuthenticationRepresentation["copyFrom"] is not None: + authenticationRepresentation = kc.copy_auth_flow(config=newAuthenticationRepresentation, realm=realm) + else: # Create an empty authentication flow + authenticationRepresentation = kc.create_empty_auth_flow(config=newAuthenticationRepresentation, realm=realm) + # If the authentication still not exist on the server, raise an exception. + if authenticationRepresentation is None: + result['msg'] = "Authentication just created not found: " + str(newAuthenticationRepresentation) + result['changed'] = changed + module.fail_json(**result) + # Configure the executions for the flow + if kc.create_or_update_executions(config=newAuthenticationRepresentation, realm=realm): + changed = True + # Get executions created + executionsRepresentation = kc.get_executions_representation(config=newAuthenticationRepresentation, realm=realm) + if executionsRepresentation is not None: + authenticationRepresentation["authenticationExecutions"] = executionsRepresentation + result['flow'] = authenticationRepresentation + result['changed'] = changed + elif state == 'absent': # If desired state is absent + # Delete the authentication flow alias. + kc.delete_authentication_flow_by_id(id=authenticationRepresentation["id"], realm=realm) + changed = True + result['msg'] = 'Authentication flow: ' + newAuthenticationRepresentation['alias'] + ' id: ' + authenticationRepresentation["id"] + ' is deleted' + result['changed'] = changed + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py index fe6984dae960cd..c4d0117b1ed440 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_client.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_client.py @@ -367,7 +367,13 @@ 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. - + state: + description: + - Desired state of the protocol mappers. + If present, the mapper will be added or updated. + If absent, the mapper will be removed + choices: [absent, present] + default: present attributes: description: - A dict of further attributes for this client. This can contain various configuration @@ -473,7 +479,44 @@ description: - For OpenID-Connect clients, client certificate for validating JWT issued by client and signed by its key, base64-encoded. - + client_roles: + description: + - List of roles and their composites for the client. + Client roles can be added, updated or removed depending it's state. + aliases: + - clientRoles + - roles + type: list + suboptions: + name: + description: + - Name of the role + description: + description: + - Description of the role + composite: + description: + - Is the role is a composite or not. + type: bool + default: False if no composites are includes, True if compistes are included + composites: + description: + - List of composite roles + type: list + suboptions: + id: + description: + - clientId of the client if the composite is a client role. + name: + description: + - Name of the role. It can be a realm role name or a client role name. + version_added: "2.9" + force: + type: bool + description: + - If true, existing client will be deleted an re-created. + default: False + version_added: "2.9" extends_documentation_fragment: - keycloak @@ -574,6 +617,19 @@ name: role list protocol: saml protocolMapper: saml-role-list-mapper + - config: + multivalued: False + userinfo.token.claim": True + user.attribute: Test2 + id.token.claim: True + access.token.claim: True + claim.name: test2 + jsonType.label: String + name: thismappermustbedeleted + protocol: openid-connect + protocolMapper: oidc-usermodel-attribute-mapper + consentRequired: False + state: absent attributes: saml.authnstatement: True saml.client.signature: True @@ -590,6 +646,20 @@ use.jwks.url: true jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH + client_roles: + - name: role1 + description: This is the first role + composite: False + - name: role2 + description: This is the second role. + composite: True + composites: + - name: realmRole1 + - id: clientId1 + name: clientRole1 + - name: roleToBeDeleted + description: This role need to be deleted + state: absent ''' RETURN = ''' @@ -627,7 +697,6 @@ } } ''' - from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec from ansible.module_utils.basic import AnsibleModule @@ -663,12 +732,22 @@ def main(): protocol=dict(type='str', choices=['openid-connect', 'saml']), protocolMapper=dict(type='str'), config=dict(type='dict'), + state=dict(type='str', choices=['absent', 'present'], default='present'), + ) + clientrolecomposites_spec = dict( + name=dict(type='str'), + id=dict(type='str'), + ) + clientroles_spec = dict( + name=dict(type='str'), + description=dict(type='str'), + composite=dict(type='bool'), + composites=dict(type='list', elements='dict', options=clientrolecomposites_spec), + state=dict(type='str', choices=['absent', 'present'], default='present'), ) - 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'), @@ -694,7 +773,7 @@ def main(): 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']), + protocol=dict(type='str', choices=['openid-connect', 'saml'], default='openid-connect'), attributes=dict(type='dict'), full_scope_allowed=dict(type='bool', aliases=['fullScopeAllowed']), node_re_registration_timeout=dict(type='int', aliases=['nodeReRegistrationTimeout']), @@ -705,6 +784,8 @@ def main(): use_template_mappers=dict(type='bool', aliases=['useTemplateMappers']), protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']), authorization_settings=dict(type='dict', aliases=['authorizationSettings']), + client_roles=dict(type='list', elements='dict', options=clientroles_spec, aliases=['clientRoles', 'roles']), + force=dict(type='bool', default=False), ) argument_spec.update(meta_args) @@ -723,7 +804,7 @@ def main(): # convert module parameters to client representation parameters (if they belong in there) client_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'url', 'force'] and module.params.get(x) is not None] keycloak_argument_spec().keys() # See whether the client already exists in Keycloak @@ -755,6 +836,8 @@ def main(): 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] + if client_param == 'roles': + client_param = 'client_roles' changeset[camel(client_param)] = new_param_value # Whether creating or updating a client, take the before-state and merge the changeset into it @@ -788,6 +871,9 @@ def main(): after_client = kc.get_client_by_clientid(updated_client['clientId'], realm=realm) result['end_state'] = sanitize_cr(after_client) + client_secret = kc.get_client_secret_by_id(after_client['id'], realm=realm) + if client_secret is not None: + result['clientSecret'] = client_secret result['msg'] = 'Client %s has been created.' % updated_client['clientId'] module.exit_json(**result) @@ -807,6 +893,9 @@ def main(): kc.update_client(cid, updated_client, realm=realm) after_client = kc.get_client_by_id(cid, realm=realm) + client_secret = kc.get_client_secret_by_id(cid, realm=realm) + if client_secret is not None: + result['clientSecret'] = client_secret if before_client == after_client: result['changed'] = False if module._diff: diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group.py b/lib/ansible/modules/identity/keycloak/keycloak_group.py index 0d6ba686b5d885..996bd5939fc4b2 100644 --- a/lib/ansible/modules/identity/keycloak/keycloak_group.py +++ b/lib/ansible/modules/identity/keycloak/keycloak_group.py @@ -75,10 +75,63 @@ description: - A dict of key/value pairs to set as custom attributes for the group. - Values may be single values (e.g. a string) or a list of strings. + attributes_list: + type: list + description: + - A dict of key/value pairs list to set as custom attributes for the group. + - Those attributes will be added to attributes dict. + - The purpose of this option is to be able tu user Ansible variable as attribute name. + suboptions: + name: + description: + - Name of the attribute + type: str + value: + description: + - Value of the attribute + type: str + version_added: 2.9 + realmRoles: + type: list + description: + - List of realm roles to assign to the group. + version_added: 2.9 + clientRoles: + type: list + description: + - List of client roles to assign to group. + suboptions: + clientid: + type: str + description: + - Client Id of the client role + roles: + type: list + description: + - List of roles for this client to assing to group + version_added: 2.9 + path: + description: + Group path + version_added: 2.9 + syncLdapMappers: + type: bool + description: + - If true, groups will be synchronized between Keycloak and LDAP. + - All user storages defined as user federation will be synchronized. + - A sync is done from LDAP to Keycloak before doing the job and from Keycloak to LDAP after. + default: False + version_added: 2.9 + force: + type: bool + description: + - If true and the group already exist on the Keycloak server, it will be deleted and re-created with the new specification. + default: False + version_added: 2.9 notes: - - Presently, the I(realmRoles), I(clientRoles) and I(access) attributes returned by the Keycloak API - are read-only for groups. This limitation will be removed in a later version of this module. + - Presently, the I(access) attribute returned by the Keycloak API is read-only for groups. + This version of this module now support the I(realmRoles), I(clientRoles) as read-write attributes. extends_documentation_fragment: - keycloak @@ -152,6 +205,29 @@ - individual - list - items + attributes_list: + - name: '{{ an_ansible_variable }}' + value: '{{ another_ansible_variable }}' + - name: attrib4 + value: value4 + delegate_to: localhost + +- name: Create a keycloak group with some roles + keycloak_group: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my_group_with_roles + realmRoles: + - admin + - another-realm-role + clientRoles: + clientid: master-realm + roles: + - manage-users + - view-identity-providers delegate_to: localhost ''' @@ -223,7 +299,13 @@ def main(): realm=dict(default='master'), id=dict(type='str'), name=dict(type='str'), - attributes=dict(type='dict') + attributes=dict(type='dict'), + path=dict(type='str'), + attributes_list=dict(type='list'), + realmRoles=dict(type='list'), + clientRoles=dict(type='list'), + syncLdapMappers=dict(type='bool', default=False), + force=dict(type='bool', default=False), ) argument_spec.update(meta_args) @@ -242,9 +324,18 @@ def main(): gid = module.params.get('id') name = module.params.get('name') attributes = module.params.get('attributes') + # Add attribute received as a list to the attributes dict + kc.add_attributes_list_to_attributes_dict(module.params.get('attributes_list'), attributes) + syncLdapMappers = module.params.get('syncLdapMappers') + groupRealmRoles = module.params.get('realmRoles') + groupClientRoles = module.params.get('clientRoles') + force = module.params.get('force') before_group = None # current state of the group, for merging. + # Synchronize LDAP group to Keycloak if syncLdapMappers is true + if syncLdapMappers: + kc.sync_ldap_groups("fedToKeycloak", realm=realm) # does the group already exist? if gid is None: before_group = kc.get_group_by_name(name, realm=realm) @@ -259,11 +350,10 @@ def main(): if attributes is not None: for key, val in module.params['attributes'].items(): module.params['attributes'][key] = [val] if not isinstance(val, list) else val - + excludes = ['state', 'realm', 'force', 'attributes_list', 'realmRoles', 'clientRoles', 'syncLdapMappers'] group_params = [x for x in module.params - if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + if x not in list(keycloak_argument_spec().keys()) + excludes and module.params.get(x) is not None] - # build a changeset changeset = {} for param in group_params: @@ -299,6 +389,12 @@ def main(): # do it for real! kc.create_group(updated_group, realm=realm) + # Assing roles to group + kc.assing_roles_to_group(groupRepresentation=updated_group, groupRealmRoles=groupRealmRoles, groupClientRoles=groupClientRoles, realm=realm) + # Sync Keycloak groups to User Storages if syncLdapMappers is true + if syncLdapMappers: + kc.sync_ldap_groups("keycloakToFed", realm=realm) + after_group = kc.get_group_by_name(name, realm) result['group'] = after_group @@ -308,7 +404,7 @@ def main(): else: if state == 'present': # no changes - if updated_group == before_group: + if updated_group == before_group and not force: result['changed'] = False result['group'] = updated_group result['msg'] = "No changes required to group {name}.".format(name=before_group['name']) @@ -323,10 +419,32 @@ def main(): if module.check_mode: module.exit_json(**result) - # do the update - kc.update_group(updated_group, realm=realm) - - after_group = kc.get_group_by_groupid(updated_group['id'], realm=realm) + if force: + # delete for real + gid = before_group['id'] + kc.delete_group(groupid=gid, realm=realm) + # remove id + del(updated_group['id']) + if "realmRoles" in updated_group: + del(updated_group['realmRoles']) + if "clientRoles" in updated_group: + del(updated_group['clientRoles']) + + # create it again + kc.create_group(updated_group, realm=realm) + else: + # do the update + kc.update_group(updated_group, realm=realm) + # Assing roles to group + kc.assing_roles_to_group(groupRepresentation=updated_group, groupRealmRoles=groupRealmRoles, groupClientRoles=groupClientRoles, realm=realm) + # Sync Keycloak groups to User Storages if syncLdapMappers is true + if syncLdapMappers: + kc.sync_ldap_groups("keycloakToFed", realm=realm) + + if force: + after_group = kc.get_group_by_name(name, realm) + else: + after_group = kc.get_group_by_groupid(updated_group['id'], realm=realm) result['group'] = after_group result['msg'] = "Group {id} has been updated".format(id=after_group['id']) @@ -345,6 +463,9 @@ def main(): # delete for real gid = before_group['id'] kc.delete_group(groupid=gid, realm=realm) + # Sync Keycloak groups to User Storages if syncLdapMappers is true + if syncLdapMappers: + kc.sync_ldap_groups("keycloakToFed", realm=realm) result['changed'] = True result['msg'] = "Group {name} has been deleted".format(name=before_group['name']) diff --git a/lib/ansible/plugins/doc_fragments/keycloak.py b/lib/ansible/plugins/doc_fragments/keycloak.py index edac1a8f36f995..71ef1a62b48099 100644 --- a/lib/ansible/plugins/doc_fragments/keycloak.py +++ b/lib/ansible/plugins/doc_fragments/keycloak.py @@ -14,8 +14,6 @@ class ModuleDocFragment(object): - URL to the Keycloak instance. type: str required: true - aliases: - - url auth_client_id: description: @@ -28,7 +26,7 @@ class ModuleDocFragment(object): description: - Keycloak realm name to authenticate to for API access. type: str - required: true + default: master auth_client_secret: description: @@ -40,16 +38,12 @@ class ModuleDocFragment(object): - Username to authenticate for API access with. type: str required: true - aliases: - - username auth_password: description: - Password to authenticate for API access with. type: str required: true - aliases: - - password validate_certs: description: