# 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 annotations DOCUMENTATION = r""" module: keycloak_client_scope_rolemappings short_description: Allows administration of Keycloak client scope scope mappings to restrict the usage of certain roles to specific client scopes # Originally added in community.general 13.1.0 version_added: "3.0.0" description: - This module allows you to add or remove Keycloak roles from client scopes using the Keycloak REST API. It requires access to the REST API using OpenID Connect; the user connecting and the client being used must have the requisite access rights. In a default Keycloak installation, C(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. - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and are returned that way by this module. You may pass single values for attributes when calling the module, and this is translated into a list suitable for the API. attributes: check_mode: support: full diff_mode: support: full action_group: # Originally added in community.general 13.1.0 version_added: "3.0.0" options: state: description: - State of the role mapping. - On V(present), all roles in O(role_names) are mapped if not exist yet. - On V(absent), all roles mapping in O(role_names) are removed if they exist. default: 'present' type: str choices: - present - absent realm: type: str description: - The Keycloak realm under which clients resides. default: 'master' client_scope_id: required: true type: str description: - Roles provided in O(role_names) will be added to this client scope. client_id: type: str description: - If the O(role_names) are client roles, the client ID under which it resides. - If this parameter is absent, the roles are considered realm roles. role_names: required: true type: list elements: str description: - Names of roles to add. - If O(client_id) is present, all roles must be under this client. - If O(client_id) is absent, all roles must be under the realm. extends_documentation_fragment: - middleware_automation.keycloak.keycloak - middleware_automation.keycloak.actiongroup_keycloak - middleware_automation.keycloak.attributes author: - Felix Grzelka (@felix-grzelka) # This module was adapted from keycloak_client_rolescope, which was written by Andre Desrosiers (@desand01). """ EXAMPLES = r""" - name: Add roles to client scope middleware_automation.keycloak.keycloak_client_scope_rolemappings: auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_id: frontend-client-public client_scope_id: frontend-client-scope role_names: - backend-role-admin - backend-role-user - name: Remove roles from client scope middleware_automation.keycloak.keycloak_client_scope_rolemappings: auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_id: frontend-client-public client_scope_id: frontend-client-scope role_names: - backend-role-admin state: absent - name: Add realm roles to client scope middleware_automation.keycloak.keycloak_client_scope_rolemappings: auth_keycloak_url: https://auth.example.com auth_realm: master auth_username: USERNAME auth_password: PASSWORD realm: MyCustomRealm client_scope_id: frontend-client-scope role_names: - realm-role-admin - realm-role-user """ RETURN = r""" end_state: description: Representation of client scope scope mappings after module execution. returned: on success type: list elements: dict sample: [ { "clientRole": false, "composite": false, "containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f", "description": "User role", "id": "9e155ef7-86f5-4def-b507-581ce7b87013", "name": "realm-role-user" }, { "clientRole": false, "composite": false, "containerId": "77f9bd4e-13a6-451e-9c72-ee6997299c1f", "description": "Admin role", "id": "9e155ef7-86f5-4def-b507-581ce7b87013", "name": "realm-role-admin" } ] """ import copy from ansible.module_utils.basic import AnsibleModule from ansible_collections.middleware_automation.keycloak.plugins.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, KeycloakError, get_token, keycloak_argument_spec, ) def main(): argument_spec = keycloak_argument_spec() meta_args = dict( client_id=dict(type="str"), client_scope_id=dict(type="str", required=True), 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["realm"] client_id = module.params["client_id"] client_scope_id = module.params["client_scope_id"] role_names = module.params["role_names"] state = module.params["state"] realm_object = kc.get_realm_by_id(realm) if not realm_object: module.fail_json(msg=f"Failed to retrieve realm '{realm}'") client_scope_object = kc.get_client_scope_by_name(client_scope_id, realm) if not client_scope_object: module.fail_json(msg=f"Failed to retrieve client scope '{client_scope_id}'") if client_id: # add client role client_object = kc.get_client_by_client_id(client_id, realm) if not client_object: module.fail_json(msg=f"Failed to retrieve client '{realm}.{client_id}'") if client_object["fullScopeAllowed"] and state == "present": module.fail_json(msg=f"FullScopeAllowed is active for Client '{realm}.{client_id}'") before_roles = kc.get_client_scope_scope_mappings_client(client_scope_object["id"], client_object["id"], realm) available_roles_by_name = kc.get_client_roles_by_id(client_object["id"], realm) else: # add realm role before_roles = kc.get_client_scope_scope_mappings_realm(client_scope_object["id"], realm) available_roles_by_name = kc.get_realm_roles(realm) # convert to indexed Dict by name available_roles_by_name = {role["name"]: role for role in available_roles_by_name} before_roles_by_name = {role["name"]: role for role in before_roles} desired_roles = copy.deepcopy(before_roles) changed_roles = [] if state == "present": # update desired for role_name in role_names: if role_name not in available_roles_by_name: if client_id: module.fail_json(msg=f"Failed to retrieve role '{realm}.{client_id}.{role_name}'") else: module.fail_json(msg=f"Failed to retrieve role '{realm}.{role_name}'") if role_name not in before_roles_by_name: changed_roles.append(available_roles_by_name[role_name]) desired_roles.append(available_roles_by_name[role_name]) else: # remove role if present for role_name in role_names: if role_name in before_roles_by_name: changed_roles.append(before_roles_by_name[role_name]) desired_roles.remove(available_roles_by_name[role_name]) before_roles = sorted(before_roles, key=lambda d: d["name"]) desired_role_mapping = sorted(desired_roles, key=lambda d: d["name"]) result["changed"] = bool(changed_roles) if module._diff: result["diff"] = dict(before={"roles": before_roles}, after={"roles": desired_role_mapping}) if not result["changed"]: # no changes result["end_state"] = before_roles result["msg"] = f"No changes required for client scope {client_scope_id}." elif state == "present": # doing update if module.check_mode: result["end_state"] = desired_role_mapping elif client_id: result["end_state"] = kc.update_client_scope_scope_mappings_client( changed_roles, client_scope_object["id"], client_object["id"], realm ) else: result["end_state"] = kc.update_client_scope_scope_mappings_realm( changed_roles, client_scope_object["id"], realm ) result["msg"] = f"Clientscope scope mappings for {client_scope_id} have been updated" else: # doing delete if module.check_mode: result["end_state"] = desired_role_mapping elif client_id: result["end_state"] = kc.delete_client_scope_scope_mappings_client( changed_roles, client_scope_object["id"], client_object["id"], realm ) else: result["end_state"] = kc.delete_client_scope_scope_mappings_realm( changed_roles, client_scope_object["id"], realm ) result["msg"] = f"Clientscope scope mappings for {client_scope_id} have been deleted" module.exit_json(**result) if __name__ == "__main__": main()