#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) Ansible project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- module: keycloak_client_rolescope short_description: Allows administration of Keycloak client roles scope to restrict the usage of certain roles to a other specific client applications. version_added: 8.6.0 description: - This module allows you to add or remove Keycloak roles from clients scope via the Keycloak REST API. It requires access to the REST API via OpenID Connect; the user connecting and the client being used must have the requisite access rights. In a default Keycloak installation, admin-cli and an admin user would work, as would a separate client definition with the scope tailored to your needs and a user having the expected roles. - Client O(client_id) must have O(community.general.keycloak_client#module:full_scope_allowed) set to V(false). - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will be returned that way by this module. You may pass single values for attributes when calling the module, and this will be translated into a list suitable for the API. attributes: check_mode: support: full diff_mode: support: full options: state: description: - State of the role mapping. - On V(present), all roles in O(role_names) will be mapped if not exists yet. - On V(absent), all roles mapping in O(role_names) will be removed if it exists. default: 'present' type: str choices: - present - absent realm: type: str description: - The Keycloak realm under which clients resides. default: 'master' client_id: type: str required: true description: - Roles provided in O(role_names) while be added to this client scope. client_scope_id: type: str description: - If the O(role_names) are client role, the client ID under which it resides. - If this parameter is absent, the roles are considered a realm role. role_names: required: true type: list elements: str description: - Names of roles to manipulate. - If O(client_scope_id) is present, all roles must be under this client. - If O(client_scope_id) is absent, all roles must be under the realm. extends_documentation_fragment: - community.general.keycloak - community.general.attributes author: - Andre Desrosiers (@desand01) ''' EXAMPLES = ''' - name: Add roles to public client scope community.general.keycloak_client_rolescope: auth_keycloak_url: https://auth.example.com/auth auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_id: frontend-client-public client_scope_id: backend-client-private role_names: - backend-role-admin - backend-role-user - name: Remove roles from public client scope community.general.keycloak_client_rolescope: auth_keycloak_url: https://auth.example.com/auth auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_id: frontend-client-public client_scope_id: backend-client-private role_names: - backend-role-admin state: absent - name: Add realm roles to public client scope community.general.keycloak_client_rolescope: auth_keycloak_url: https://auth.example.com/auth auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_id: frontend-client-public role_names: - realm-role-admin - realm-role-user ''' RETURN = ''' msg: description: Message as to what action was taken. returned: always type: str sample: "Client role scope for frontend-client-public has been updated" end_state: description: Representation of role role scope after module execution. returned: on success type: list elements: dict sample: [ { "clientRole": false, "composite": false, "containerId": "MyCustomRealm", "id": "47293104-59a6-46f0-b460-2e9e3c9c424c", "name": "backend-role-admin" }, { "clientRole": false, "composite": false, "containerId": "MyCustomRealm", "id": "39c62a6d-542c-4715-92d2-41021eb33967", "name": "backend-role-user" } ] ''' from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule def main(): """ Module execution :return: """ argument_spec = keycloak_argument_spec() meta_args = dict( client_id=dict(type='str', required=True), client_scope_id=dict(type='str'), realm=dict(type='str', default='master'), role_names=dict(type='list', elements='str', required=True), state=dict(type='str', default='present', choices=['present', 'absent']), ) argument_spec.update(meta_args) module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) result = dict(changed=False, msg='', diff={}, end_state={}) # Obtain access token, initialize API try: connection_header = get_token(module.params) except KeycloakError as e: module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) realm = module.params.get('realm') clientid = module.params.get('client_id') client_scope_id = module.params.get('client_scope_id') role_names = module.params.get('role_names') state = module.params.get('state') objRealm = kc.get_realm_by_id(realm) if not objRealm: module.fail_json(msg="Failed to retrive realm '{realm}'".format(realm=realm)) objClient = kc.get_client_by_clientid(clientid, realm) if not objClient: module.fail_json(msg="Failed to retrive client '{realm}.{clientid}'".format(realm=realm, clientid=clientid)) if objClient["fullScopeAllowed"] and state == "present": module.fail_json(msg="FullScopeAllowed is active for Client '{realm}.{clientid}'".format(realm=realm, clientid=clientid)) if client_scope_id: objClientScope = kc.get_client_by_clientid(client_scope_id, realm) if not objClientScope: module.fail_json(msg="Failed to retrive client '{realm}.{client_scope_id}'".format(realm=realm, client_scope_id=client_scope_id)) before_role_mapping = kc.get_client_role_scope_from_client(objClient["id"], objClientScope["id"], realm) else: before_role_mapping = kc.get_client_role_scope_from_realm(objClient["id"], realm) if client_scope_id: # retrive all role from client_scope client_scope_roles_by_name = kc.get_client_roles_by_id(objClientScope["id"], realm) else: # retrive all role from realm client_scope_roles_by_name = kc.get_realm_roles(realm) # convert to indexed Dict by name client_scope_roles_by_name = {role["name"]: role for role in client_scope_roles_by_name} role_mapping_by_name = {role["name"]: role for role in before_role_mapping} role_mapping_to_manipulate = [] if state == "present": # update desired for role_name in role_names: if role_name not in client_scope_roles_by_name: if client_scope_id: module.fail_json(msg="Failed to retrive role '{realm}.{client_scope_id}.{role_name}'" .format(realm=realm, client_scope_id=client_scope_id, role_name=role_name)) else: module.fail_json(msg="Failed to retrive role '{realm}.{role_name}'".format(realm=realm, role_name=role_name)) if role_name not in role_mapping_by_name: role_mapping_to_manipulate.append(client_scope_roles_by_name[role_name]) role_mapping_by_name[role_name] = client_scope_roles_by_name[role_name] else: # remove role if present for role_name in role_names: if role_name in role_mapping_by_name: role_mapping_to_manipulate.append(role_mapping_by_name[role_name]) del role_mapping_by_name[role_name] before_role_mapping = sorted(before_role_mapping, key=lambda d: d['name']) desired_role_mapping = sorted(role_mapping_by_name.values(), key=lambda d: d['name']) result['changed'] = len(role_mapping_to_manipulate) > 0 if result['changed']: result['diff'] = dict(before=before_role_mapping, after=desired_role_mapping) if not result['changed']: # no changes result['end_state'] = before_role_mapping result['msg'] = "No changes required for client role scope {name}.".format(name=clientid) elif state == "present": # doing update if module.check_mode: result['end_state'] = desired_role_mapping elif client_scope_id: result['end_state'] = kc.update_client_role_scope_from_client(role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm) else: result['end_state'] = kc.update_client_role_scope_from_realm(role_mapping_to_manipulate, objClient["id"], realm) result['msg'] = "Client role scope for {name} has been updated".format(name=clientid) else: # doing delete if module.check_mode: result['end_state'] = desired_role_mapping elif client_scope_id: result['end_state'] = kc.delete_client_role_scope_from_client(role_mapping_to_manipulate, objClient["id"], objClientScope["id"], realm) else: result['end_state'] = kc.delete_client_role_scope_from_realm(role_mapping_to_manipulate, objClient["id"], realm) result['msg'] = "Client role scope for {name} has been deleted".format(name=clientid) module.exit_json(**result) if __name__ == '__main__': main()