From 20a07fc973525e626d26e9fd7fc854ecc651e5a1 Mon Sep 17 00:00:00 2001 From: felix-grzelka Date: Sat, 30 May 2026 13:45:37 +0200 Subject: [PATCH] new module: keycloak_clientscope_rolemappings (#11841) * init * stuff * this should work * helper functions * fix docstrings * s/client scope/clientscope/ * fix docstrings * add type hints * fix old function * nox -Re formatters * fix clientscope_id * fix blank line contains whitespace * add BOTMETA info * set version_added * Apply suggestions from code review to prepare for 13.0.0 Co-authored-by: Felix Fontein * fix yaml indent in doc string * add keycloak_clientscope_rolemappings to keycloak action group * original author credit * Apply suggestions from code review Co-authored-by: Felix Fontein * init tests * Update plugins/modules/keycloak_clientscope_rolemappings.py Co-authored-by: Felix Fontein * fix integration tests * use [] instead of .get() * fix typo * Update plugins/modules/keycloak_clientscope_rolemappings.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * update fedora version * fix --docker fedora * revert * Apply suggestions from code review Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * remove unnecessary docstring * change something * change it back * Apply suggestions from code review Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_clientscope_rolemappings.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --------- Co-authored-by: Felix Fontein Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --- .github/BOTMETA.yml | 2 + meta/runtime.yml | 1 + plugins/module_utils/_keycloak.py | 138 +++++++ .../keycloak_clientscope_rolemappings.py | 279 ++++++++++++++ .../README.md | 20 + .../keycloak_clientscope_rolemappings/aliases | 5 + .../tasks/main.yml | 342 ++++++++++++++++++ .../vars/main.yml | 26 ++ 8 files changed, 813 insertions(+) create mode 100644 plugins/modules/keycloak_clientscope_rolemappings.py create mode 100644 tests/integration/targets/keycloak_clientscope_rolemappings/README.md create mode 100644 tests/integration/targets/keycloak_clientscope_rolemappings/aliases create mode 100644 tests/integration/targets/keycloak_clientscope_rolemappings/tasks/main.yml create mode 100644 tests/integration/targets/keycloak_clientscope_rolemappings/vars/main.yml diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 768c9bacb8..bd0afac294 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -857,6 +857,8 @@ files: maintainers: Gaetan2907 $modules/keycloak_client_rolescope.py: maintainers: desand01 + $modules/keycloak_clientscope_rolemappings.py: + maintainers: felix-grzelka $modules/keycloak_clientscope.py: maintainers: Gaetan2907 $modules/keycloak_clientscope_type.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index f3e6677c7b..bc1814aa3e 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -31,6 +31,7 @@ action_groups: - keycloak_client_rolescope - keycloak_clientscope - keycloak_clientscope_type + - keycloak_clientscope_rolemappings - keycloak_clientsecret_info - keycloak_clientsecret_regenerate - keycloak_clienttemplate diff --git a/plugins/module_utils/_keycloak.py b/plugins/module_utils/_keycloak.py index b5695f400f..a452734e84 100644 --- a/plugins/module_utils/_keycloak.py +++ b/plugins/module_utils/_keycloak.py @@ -62,6 +62,10 @@ URL_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children" URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes" URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}" +URL_CLIENTSCOPE_SCOPE_MAPPINGS = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings" +URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/realm" +URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT = "{url}/admin/realms/{realm}/client-scopes/{id}/scope-mappings/clients/{client}" + URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models" URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}" @@ -3243,6 +3247,52 @@ class KeycloakAPI: except Exception: return False + def get_all_clientscope_scope_mappings(self, clientscope_id, realm: str = "master"): + """Fetch all (realm and client) roles (scope-mappings) associated with the clientscope for a specific clientscope on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS.format(url=self.baseurl, realm=realm, id=clientscope_id) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request(e, msg=f"Could not fetch roles for client-scope {clientscope_id} in realm {realm}: {e}") + + def get_clientscope_scope_mappings_realm(self, clientscope_id, realm: str = "master"): + """Fetch the realm roles (scope-mappings) associated with the clientscope for a specific clientscope on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request( + e, msg=f"Could not fetch realm roles for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + def get_clientscope_scope_mappings_client(self, clientscope_id, client_id, realm: str = "master"): + """Fetch the client roles (scope-mappings) associated with the clientscope for a specific clientscope and client on the Keycloak server. + :param clientscope_id: ID of the clientscope from which to obtain the associated roles. + :param clientid: ID of the client from which to obtain the associated roles. + :param realm: Realm from which to obtain the scope. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + return self._request_and_deserialize(client_role_scope_url, method="GET") + except Exception as e: + self.fail_request( + e, + msg=f"Could not fetch client roles from client {client_id} for client-scope {clientscope_id} in realm {realm}: {e}", + ) + def get_client_role_scope_from_client(self, clientid, clientscopeid, realm: str = "master"): """Fetch the roles associated with the client's scope for a specific client on the Keycloak server. :param clientid: ID of the client from which to obtain the associated roles. @@ -3258,6 +3308,50 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not fetch roles scope for client {clientid} in realm {realm}: {e}") + def update_clientscope_scope_mappings_client( + self, payload: list[dict], clientscope_id: str, client_id: str, realm: str = "master" + ): + """Update and fetch the client roles (scope-mappings) associated with the clientscope on the Keycloak server. + :param payload: List of client roles to be added to the scope. + :param clientscope_id: ID of the clientscope to update scope-mappings. + :param clientid: ID of the client from which to obtain the associated roles. + :param realm: Realm from which to obtain the client. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not update scope mappings for client-scope {client_id}.{clientscope_id} in realm {realm}: {e}", + ) + + return self.get_clientscope_scope_mappings_client(clientscope_id, client_id, realm) + + def update_clientscope_scope_mappings_realm(self, payload: list[dict], clientscope_id: str, realm: str = "master"): + """Update and fetch the realm roles (scope-mappings) associated with the clientscope on the Keycloak server. + :param payload: List of realm roles to be added to the scope. + :param clientscope_id: ID of the clientscope to update scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + self._request(client_role_scope_url, method="POST", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not update scope mappings for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + return self.get_clientscope_scope_mappings_realm(clientscope_id, realm) + def update_client_role_scope_from_client(self, payload, clientid, clientscopeid, realm: str = "master"): """Update and fetch the roles associated with the client's scope on the Keycloak server. :param payload: List of roles to be added to the scope. @@ -3296,6 +3390,50 @@ class KeycloakAPI: return self.get_client_role_scope_from_client(clientid, clientscopeid, realm) + def delete_clientscope_scope_mappings_client( + self, payload: list[dict], clientscope_id: str, client_id: str, realm: str = "master" + ): + """Delete the client roles (scope_mappings) contained in the payload from the clientscope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientscope_id: ID of the clientscope to delete roles from scope-mappings. + :param clientid: ID of the client who owns the roles. + :param realm: Realm from which to obtain the client. + :return: The clientscope client scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=clientscope_id, client=client_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, + msg=f"Could not delete scope mappings for client-scope {client_id}.{clientscope_id} in realm {realm}: {e}", + ) + + return self.get_clientscope_scope_mappings_client(clientscope_id, client_id, realm) + + def delete_clientscope_scope_mappings_realm(self, payload: list[dict], clientscope_id: str, realm: str = "master"): + """Delete the realm roles (scope_mappings) contained in the payload from the clientscope on the Keycloak server. + :param payload: List of roles to be deleted. + :param clientscope_id: ID of the clientscope to delete roles from scope-mappings. + :param realm: Realm from which to obtain the roles. + :return: The clientscope realm scope-mappings. + """ + client_role_scope_url = URL_CLIENTSCOPE_SCOPE_MAPPINGS_REALM.format( + url=self.baseurl, realm=realm, id=clientscope_id + ) + try: + self._request(client_role_scope_url, method="DELETE", data=json.dumps(payload)) + + except Exception as e: + self.fail_request( + e, msg=f"Could not delete scope mappings for client-scope {clientscope_id} in realm {realm}: {e}" + ) + + return self.get_clientscope_scope_mappings_realm(clientscope_id, realm) + def get_client_role_scope_from_realm(self, clientid, realm: str = "master"): """Fetch the realm roles from the client's scope on the Keycloak server. :param clientid: ID of the client from which to obtain the associated realm roles. diff --git a/plugins/modules/keycloak_clientscope_rolemappings.py b/plugins/modules/keycloak_clientscope_rolemappings.py new file mode 100644 index 0000000000..63b84c4732 --- /dev/null +++ b/plugins/modules/keycloak_clientscope_rolemappings.py @@ -0,0 +1,279 @@ +#!/usr/bin/python + +# 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_clientscope_rolemappings + +short_description: Allows administration of Keycloak clientscope scope mappings to restrict the usage of certain roles to + specific clientscopes + +version_added: 13.1.0 + +description: + - This module allows you to add or remove Keycloak roles from clientscopes 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 + +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' + + clientscope_id: + required: true + type: str + description: + - Roles provided in O(role_names) will be added to this clientscope. + + 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: + - community.general._keycloak + - community.general._keycloak.actiongroup_keycloak + - community.general._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 clientscope + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + clientscope_id: frontend-clientscope + role_names: + - backend-role-admin + - backend-role-user + +- name: Remove roles from clientscope + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + client_id: frontend-client-public + clientscope_id: frontend-clientscope + role_names: + - backend-role-admin + state: absent + +- name: Add realm roles to clientscope + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: MyCustomRealm + clientscope_id: frontend-clientscope + role_names: + - realm-role-admin + - realm-role-user +""" + +RETURN = r""" +end_state: + description: Representation of clientscope 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.community.general.plugins.module_utils._keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + + meta_args = dict( + client_id=dict(type="str"), + clientscope_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"] + clientscope_id = module.params["clientscope_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}'") + + clientscope_object = kc.get_clientscope_by_name(clientscope_id, realm) + if not clientscope_object: + module.fail_json(msg=f"Failed to retrieve client-scope '{clientscope_id}'") + + if client_id: + # add client role + client_object = kc.get_client_by_clientid(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_clientscope_scope_mappings_client(clientscope_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_clientscope_scope_mappings_realm(clientscope_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 clientscope {clientscope_id}." + elif state == "present": + # doing update + if module.check_mode: + result["end_state"] = desired_role_mapping + elif client_id: + result["end_state"] = kc.update_clientscope_scope_mappings_client( + changed_roles, clientscope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.update_clientscope_scope_mappings_realm( + changed_roles, clientscope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {clientscope_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_clientscope_scope_mappings_client( + changed_roles, clientscope_object["id"], client_object["id"], realm + ) + else: + result["end_state"] = kc.delete_clientscope_scope_mappings_realm( + changed_roles, clientscope_object["id"], realm + ) + result["msg"] = f"Clientscope scope mappings for {clientscope_id} have been deleted" + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/keycloak_clientscope_rolemappings/README.md b/tests/integration/targets/keycloak_clientscope_rolemappings/README.md new file mode 100644 index 0000000000..85b5a8ec82 --- /dev/null +++ b/tests/integration/targets/keycloak_clientscope_rolemappings/README.md @@ -0,0 +1,20 @@ + +# Running keycloak_clientscope_rolemappings module integration test + +To run Keycloak component info module's integration test, start a keycloak server using Docker: + + docker run -d --rm --name mykeycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:latest start-dev --http-relative-path /auth + +Run integration tests: + + ansible-test integration -v keycloak_clientscope_rolemappings --allow-unsupported --docker fedora --docker-network host + +Cleanup: + + docker stop mykeycloak + + diff --git a/tests/integration/targets/keycloak_clientscope_rolemappings/aliases b/tests/integration/targets/keycloak_clientscope_rolemappings/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_clientscope_rolemappings/aliases @@ -0,0 +1,5 @@ +# 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 + +unsupported diff --git a/tests/integration/targets/keycloak_clientscope_rolemappings/tasks/main.yml b/tests/integration/targets/keycloak_clientscope_rolemappings/tasks/main.yml new file mode 100644 index 0000000000..47310584e3 --- /dev/null +++ b/tests/integration/targets/keycloak_clientscope_rolemappings/tasks/main.yml @@ -0,0 +1,342 @@ +--- +# 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 +- name: Wait for Keycloak + uri: + url: "{{ url }}/admin/" + status_code: 200 + validate_certs: false + register: result + until: result.status == 200 + retries: 10 + delay: 10 + +- name: Delete realm if exists + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + state: absent + +- 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 a Keycloak realm role + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ item }}" + realm: "{{ realm }}" + with_items: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + +- name: Create client + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + state: present + public_client: true + full_scope_allowed: false + +- name: Create full scope client + community.general.keycloak_client: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ full_scope_client_name }}" + state: present + public_client: true + full_scope_allowed: true + +- name: Create a Keycloak client roles + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ item }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + with_items: + - "{{ client_role_admin }}" + - "{{ client_role_user }}" + +- name: Create a Keycloak client roles in full scope client + community.general.keycloak_role: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + name: "{{ item }}" + realm: "{{ realm }}" + client_id: "{{ full_scope_client_name }}" + with_items: + - "{{ client_role_admin }}" + - "{{ client_role_user }}" + +- name: Create clientscopes + community.general.keycloak_clientscope: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + + realm: "{{ realm }}" + name: "{{ clientscope_id }}" + description: "" + protocol: "openid-connect" + state: "present" + + +- name: Map client roles + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_user }}" + register: result + +- name: Assert mapping created + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Remap the user client role + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ client_role_user }}" + register: result + +- name: Assert no change + assert: + that: + - result is not changed + - result.end_state | length == 2 + +- name: Remove admin role + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ client_role_admin }}" + state: absent + register: result + +- name: Assert mapping deleted + assert: + that: + - result is changed + - result.end_state | length == 1 + - result.end_state[0].name == client_role_user + +- name: Map non exisiting client role + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_not_exists }}" + ignore_errors: true + register: result + +- name: Assert failed mapping missing role + assert: + that: + - result is failed + - "result.msg == 'Failed to retrieve role \\'myrealm.backend-client.client-role-missing\\''" + +- name: Map roles duplicate + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + client_id: "{{ client_name }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ client_role_admin }}" + - "{{ client_role_admin }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Map full scope client roles + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + client_id: "{{ full_scope_client_name }}" + role_names: + - "{{ client_role_admin }}" + ignore_errors: true + register: result + +- name: Assert failed mapping role to full scope client + assert: + that: + - result is failed + - "result.msg == 'FullScopeAllowed is active for Client \\'myrealm.full-scope-client\\''" + +- name: Map realm role + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ realm_role_admin }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 1 + +- name: Map two realm roles + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Unmap all realm roles + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + state: absent + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 0 + +- name: Map non exisiting realm role + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ realm_role_not_exists }}" + ignore_errors: true + register: result + +- name: Assert failed mapping missing realm role + assert: + that: + - result is failed + - "result.msg == 'Failed to retrieve role \\'myrealm.client-role-missing\\''" + +- name: Check-mode try to Map realm roles to public client + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: + - "{{ realm_role_admin }}" + - "{{ realm_role_user }}" + check_mode: true + register: result + +- name: Assert result + assert: + that: + - result is changed + - result.end_state | length == 2 + +- name: Check-mode step two, check if change where applied + community.general.keycloak_clientscope_rolemappings: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + clientscope_id: "{{ clientscope_id }}" + role_names: [] + register: result + +- name: Assert result + assert: + that: + - result is not changed + - result.end_state | length == 0 diff --git a/tests/integration/targets/keycloak_clientscope_rolemappings/vars/main.yml b/tests/integration/targets/keycloak_clientscope_rolemappings/vars/main.yml new file mode 100644 index 0000000000..9e49e92fa7 --- /dev/null +++ b/tests/integration/targets/keycloak_clientscope_rolemappings/vars/main.yml @@ -0,0 +1,26 @@ +--- +# 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 + +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm + + +client_name: backend-client +full_scope_client_name: full-scope-client +client_role_admin: client-role-admin +client_role_user: client-role-user +client_role_not_exists: client-role-missing + +clientscope_id: "client-scope" + +realm_role_admin: realm-role-admin +realm_role_user: realm-role-user +realm_role_not_exists: client-role-missing + + +client_attributes1: {"backchannel.logout.session.required": true, "backchannel.logout.revoke.offline.tokens": false, "client.secret.creation.time": 0}