From f7bc6964be2d052278bf291f689dc2b85c609013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Gro=C3=9F?= Date: Thu, 28 Dec 2023 18:11:32 +0100 Subject: [PATCH] Add keycloak_realm_rolemapping module to map realm roles to groups (#7663) * Add keycloak_realm_rolemapping module to map realm roles to groups * Whitespace * Description in plain English * Casing * Update error reporting as per #7645 * Add agross as maintainer of keycloak_realm_rolemapping module * cid and client_id are not used here * Credit other authors * mhuysamen submitted #7645 * Gaetan2907 authored keycloak_client_rolemapping.py which I took as a basis * Add integration tests * With Keycloak 23 realmRoles are only returned if assigned * Remove debug statement * Add test verifying that unmap works when no realm roles are assigned * Add license to readme * Change version number this module was added * Document which versions of the docker images have been tested * Downgrade version_added Co-authored-by: Felix Fontein --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + .../identity/keycloak/keycloak.py | 34 ++ plugins/modules/keycloak_realm_rolemapping.py | 391 ++++++++++++++++++ .../keycloak_group_rolemapping/README.md | 21 + .../keycloak_group_rolemapping/aliases | 4 + .../keycloak_group_rolemapping/tasks/main.yml | 160 +++++++ .../keycloak_group_rolemapping/vars/main.yml | 15 + 7 files changed, 627 insertions(+) create mode 100644 plugins/modules/keycloak_realm_rolemapping.py create mode 100644 tests/integration/targets/keycloak_group_rolemapping/README.md create mode 100644 tests/integration/targets/keycloak_group_rolemapping/aliases create mode 100644 tests/integration/targets/keycloak_group_rolemapping/tasks/main.yml create mode 100644 tests/integration/targets/keycloak_group_rolemapping/vars/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 7163b9cea8..a360a00cac 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -756,6 +756,8 @@ files: maintainers: laurpaum $modules/keycloak_user_rolemapping.py: maintainers: bratwurzt + $modules/keycloak_realm_rolemapping.py: + maintainers: agross mhuysamen Gaetan2907 $modules/keyring.py: maintainers: ahussey-redhat $modules/keyring_info.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 8b82b9298e..9e1c3f4d93 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -78,6 +78,8 @@ URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappi URL_CLIENT_USER_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/available" URL_CLIENT_USER_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}/composite" +URL_REALM_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{group}/role-mappings/realm" + URL_CLIENTSECRET = "{url}/admin/realms/{realm}/clients/{id}/client-secret" URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows" @@ -626,6 +628,38 @@ class KeycloakAPI(object): self.fail_open_url(e, msg="Could not assign roles to composite role %s and realm %s: %s" % (rid, realm, str(e))) + def add_group_realm_rolemapping(self, gid, role_rep, realm="master"): + """ Add the specified realm role to specified group on the Keycloak server. + + :param gid: ID of the group to add the role mapping. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + open_url(url, method="POST", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could add realm role mappings for group %s, realm %s: %s" + % (gid, realm, str(e))) + + def delete_group_realm_rolemapping(self, gid, role_rep, realm="master"): + """ Delete the specified realm role from the specified group on the Keycloak server. + + :param gid: ID of the group from which to obtain the rolemappings. + :param role_rep: Representation of the role to assign. + :param realm: Realm from which to obtain the rolemappings. + :return: None. + """ + url = URL_REALM_GROUP_ROLEMAPPINGS.format(url=self.baseurl, realm=realm, group=gid) + try: + open_url(url, method="DELETE", http_agent=self.http_agent, headers=self.restheaders, data=json.dumps(role_rep), + validate_certs=self.validate_certs, timeout=self.connection_timeout) + except Exception as e: + self.fail_open_url(e, msg="Could not delete realm role mappings for group %s, realm %s: %s" + % (gid, realm, str(e))) + def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): """ Fetch the composite role of a client in a specified group on the Keycloak server. diff --git a/plugins/modules/keycloak_realm_rolemapping.py b/plugins/modules/keycloak_realm_rolemapping.py new file mode 100644 index 0000000000..693cf9894a --- /dev/null +++ b/plugins/modules/keycloak_realm_rolemapping.py @@ -0,0 +1,391 @@ +#!/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_realm_rolemapping + +short_description: Allows administration of Keycloak realm role mappings into groups with the Keycloak API + +version_added: 8.2.0 + +description: + - This module allows you to add, remove or modify Keycloak realm role + mappings into groups with the Keycloak REST API. It requires access to the + REST API via OpenID Connect; the user connecting and the client being used + must have the requisite access rights. In a default Keycloak installation, + admin-cli and an admin user would work, as would a separate client + definition with the scope tailored to your needs and a user having the + expected roles. + + - The names of module options are snake_cased versions of the camelCase ones found in the + Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/18.0/rest-api/index.html). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + + - When updating a group_rolemapping, where possible provide the role ID to the module. This removes a lookup + to the API to translate the name into the role ID. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + state: + description: + - State of the realm_rolemapping. + - On C(present), the realm_rolemapping will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the realm_rolemapping will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + type: str + description: + - They Keycloak realm under which this role_representation resides. + default: 'master' + + group_name: + type: str + description: + - Name of the group to be mapped. + - This parameter is required (can be replaced by gid for less API call). + + parents: + type: list + description: + - List of parent groups for the group to handle sorted top to bottom. + - >- + Set this if your group is a subgroup and you do not provide the GID in O(gid). + elements: dict + suboptions: + id: + type: str + description: + - Identify parent by ID. + - Needs less API calls than using O(parents[].name). + - A deep parent chain can be started at any point when first given parent is given as ID. + - Note that in principle both ID and name can be specified at the same time + but current implementation only always use just one of them, with ID + being preferred. + name: + type: str + description: + - Identify parent by name. + - Needs more internal API calls than using O(parents[].id) to map names to ID's under the hood. + - When giving a parent chain with only names it must be complete up to the top. + - Note that in principle both ID and name can be specified at the same time + but current implementation only always use just one of them, with ID + being preferred. + gid: + type: str + description: + - ID of the group to be mapped. + - This parameter is not required for updating or deleting the rolemapping but + providing it will reduce the number of API calls required. + + roles: + description: + - Roles to be mapped to the group. + type: list + elements: dict + suboptions: + name: + type: str + description: + - Name of the role_representation. + - This parameter is required only when creating or updating the role_representation. + id: + type: str + description: + - The unique identifier for this role_representation. + - This parameter is not required for updating or deleting a role_representation but + providing it will reduce the number of API calls required. + +extends_documentation_fragment: + - community.general.keycloak + - community.general.attributes + +author: + - Gaëtan Daubresse (@Gaetan2907) + - Marius Huysamen (@mhuysamen) + - Alexander Groß (@agross) +''' + +EXAMPLES = ''' +- name: Map a client role to a group, authentication with credentials + community.general.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a group, authentication with token + community.general.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + state: present + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a subgroup, authentication with token + community.general.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + state: present + group_name: subgroup1 + parents: + - name: parent-group + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap realm role from a group + community.general.keycloak_realm_rolemapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: absent + group_name: group1 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role role1 assigned to group group1." + +proposed: + description: Representation of proposed client role mapping. + returned: always + type: dict + sample: { + clientId: "test" + } + +existing: + description: + - Representation of existing client role mapping. + - The sample is truncated. + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } + +end_state: + description: + - Representation of client role mapping after module execution. + - The sample is truncated. + returned: on success + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible_collections.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() + + roles_spec = dict( + name=dict(type='str'), + id=dict(type='str'), + ) + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + gid=dict(type='str'), + group_name=dict(type='str'), + parents=dict( + type='list', elements='dict', + options=dict( + id=dict(type='str'), + name=dict(type='str') + ), + ), + roles=dict(type='list', elements='dict', options=roles_spec), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + try: + connection_header = get_token(module.params) + except KeycloakError as e: + module.fail_json(msg=str(e)) + + kc = KeycloakAPI(module, connection_header) + + realm = module.params.get('realm') + state = module.params.get('state') + gid = module.params.get('gid') + group_name = module.params.get('group_name') + roles = module.params.get('roles') + parents = module.params.get('parents') + + # Check the parameters + if gid is None and group_name is None: + module.fail_json(msg='Either the `group_name` or `gid` has to be specified.') + + # Get the potential missing parameters + if gid is None: + group_rep = kc.get_group_by_name(group_name, realm=realm, parents=parents) + if group_rep is not None: + gid = group_rep['id'] + else: + module.fail_json(msg='Could not fetch group %s:' % group_name) + else: + group_rep = kc.get_group_by_groupid(gid, realm=realm) + + if roles is None: + module.exit_json(msg="Nothing to do (no roles specified).") + else: + for role_index, role in enumerate(roles, start=0): + if role['name'] is None and role['id'] is None: + module.fail_json(msg='Either the `name` or `id` has to be specified on each role.') + # Fetch missing role_id + if role['id'] is None: + role_rep = kc.get_realm_role(role['name'], realm=realm) + if role_rep is not None: + role['id'] = role_rep['id'] + else: + module.fail_json(msg='Could not fetch realm role %s by name:' % (role['name'])) + # Fetch missing role_name + else: + for realm_role in kc.get_realm_roles(realm=realm): + if realm_role['id'] == role['id']: + role['name'] = realm_role['name'] + break + + if role['name'] is None: + module.fail_json(msg='Could not fetch realm role %s by ID' % (role['id'])) + + assigned_roles_before = group_rep.get('realmRoles', []) + + result['existing'] = assigned_roles_before + result['proposed'] = list(assigned_roles_before) if assigned_roles_before else [] + + update_roles = [] + for role_index, role in enumerate(roles, start=0): + # Fetch roles to assign if state present + if state == 'present': + if any(assigned == role['name'] for assigned in assigned_roles_before): + pass + else: + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + result['proposed'].append(role['name']) + # Fetch roles to remove if state absent + else: + if any(assigned == role['name'] for assigned in assigned_roles_before): + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + if role['name'] in result['proposed']: # Handle double removal + result['proposed'].remove(role['name']) + + if len(update_roles): + result['changed'] = True + if module._diff: + result['diff'] = dict(before=assigned_roles_before, after=result['proposed']) + if module.check_mode: + module.exit_json(**result) + + if state == 'present': + # Assign roles + kc.add_group_realm_rolemapping(gid=gid, role_rep=update_roles, realm=realm) + result['msg'] = 'Realm roles %s assigned to groupId %s.' % (update_roles, gid) + else: + # Remove mapping of role + kc.delete_group_realm_rolemapping(gid=gid, role_rep=update_roles, realm=realm) + result['msg'] = 'Realm roles %s removed from groupId %s.' % (update_roles, gid) + + if gid is None: + assigned_roles_after = kc.get_group_by_name(group_name, realm=realm, parents=parents).get('realmRoles', []) + else: + assigned_roles_after = kc.get_group_by_groupid(gid, realm=realm).get('realmRoles', []) + result['end_state'] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result['changed'] = False + result['msg'] = 'Nothing to do, roles %s are %s with group %s.' % (roles, 'mapped' if state == 'present' else 'not mapped', group_name) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/keycloak_group_rolemapping/README.md b/tests/integration/targets/keycloak_group_rolemapping/README.md new file mode 100644 index 0000000000..db58acb7be --- /dev/null +++ b/tests/integration/targets/keycloak_group_rolemapping/README.md @@ -0,0 +1,21 @@ + + +# `keycloak_group_rolemapping` Integration Tests + +## Test Server + +Prepare a development server, tested with Keycloak versions tagged 22.0 and 23.0: + +```sh +docker run -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password --rm quay.io/keycloak/keycloak:22.0 start-dev +``` + +## Run Tests + +```sh +ansible localhost --module-name include_role --args name=keycloak_group_rolemapping +``` diff --git a/tests/integration/targets/keycloak_group_rolemapping/aliases b/tests/integration/targets/keycloak_group_rolemapping/aliases new file mode 100644 index 0000000000..9e2cd0dc49 --- /dev/null +++ b/tests/integration/targets/keycloak_group_rolemapping/aliases @@ -0,0 +1,4 @@ +# Copyright (c) 2023, Alexander Groß (@agross) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +unsupported diff --git a/tests/integration/targets/keycloak_group_rolemapping/tasks/main.yml b/tests/integration/targets/keycloak_group_rolemapping/tasks/main.yml new file mode 100644 index 0000000000..f1e6371e2f --- /dev/null +++ b/tests/integration/targets/keycloak_group_rolemapping/tasks/main.yml @@ -0,0 +1,160 @@ +# Copyright (c) 2023, Alexander Groß (@agross) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Create realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + +- name: Create realm roles + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + name: "{{ item }}" + state: present + loop: + - "{{ role_1 }}" + - "{{ role_2 }}" + +- name: Create group + community.general.keycloak_group: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + name: "{{ group }}" + state: present + +- name: Map realm roles to group + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_1 }}" + - name: "{{ role_2 }}" + state: present + register: result + +- name: Assert realm roles are assigned to group + ansible.builtin.assert: + that: + - result is changed + - result.end_state | count == 2 + +- name: Map realm roles to group again (idempotency) + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_1 }}" + - name: "{{ role_2 }}" + state: present + register: result + +- name: Assert realm roles stay assigned to group + ansible.builtin.assert: + that: + - result is not changed + +- name: Unmap realm role 1 from group + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_1 }}" + state: absent + register: result + +- name: Assert realm role 1 is unassigned from group + ansible.builtin.assert: + that: + - result is changed + - result.end_state | count == 1 + - result.end_state[0] == role_2 + +- name: Unmap realm role 1 from group again (idempotency) + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_1 }}" + state: absent + register: result + +- name: Assert realm role 1 stays unassigned from group + ansible.builtin.assert: + that: + - result is not changed + +- name: Unmap realm role 2 from group + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_2 }}" + state: absent + register: result + +- name: Assert no realm roles are assigned to group + ansible.builtin.assert: + that: + - result is changed + - result.end_state | count == 0 + +- name: Unmap realm role 2 from group again (idempotency) + community.general.keycloak_realm_rolemapping: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + group_name: "{{ group }}" + roles: + - name: "{{ role_2 }}" + state: absent + register: result + +- name: Assert no realm roles are assigned to group + ansible.builtin.assert: + that: + - result is not changed + - result.end_state | count == 0 diff --git a/tests/integration/targets/keycloak_group_rolemapping/vars/main.yml b/tests/integration/targets/keycloak_group_rolemapping/vars/main.yml new file mode 100644 index 0000000000..0848499e75 --- /dev/null +++ b/tests/integration/targets/keycloak_group_rolemapping/vars/main.yml @@ -0,0 +1,15 @@ +--- +# Copyright (c) 2023, Alexander Groß (@agross) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +url: http://localhost:8080 +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm + +role_1: myrole-1 +role_2: myrole-2 + +group: mygroup