From 6edefcd702701d1b23105182c08d48c4baa47a2e Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 17:42:56 +0800 Subject: [PATCH 01/17] feat(plugins/keycloak): add util functions for manage client scopemapping --- .../identity/keycloak/keycloak.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 0ede2dc0bab..373a2e0519f 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -49,6 +49,10 @@ URL_CLIENT_ROLE = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}" URL_CLIENT_ROLE_COMPOSITES = "{url}/admin/realms/{realm}/clients/{id}/roles/{name}/composites" +URL_CLIENT_SCOPEMAPPINGS_CLIENT = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{client}" +URL_CLIENT_SCOPEMAPPINGS_CLIENT_AVAILABLE = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{client}/available" +URL_CLIENT_SCOPEMAPPINGS_CLIENT_COMPOSITE = "{url}/admin/realms/{realm}/clients/{id}/scope-mappings/clients/{client}/composite" + 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" @@ -224,6 +228,7 @@ 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 """ + def __init__(self, module, connection_header): self.module = module self.baseurl = self.module.params.get('auth_keycloak_url') @@ -501,6 +506,89 @@ def get_client_composite_rolemappings(self, gid, cid, realm="master"): self.module.fail_json(msg="Could not fetch available rolemappings for client %s in group %s, realm %s: %s" % (cid, gid, realm, str(e))) + def add_client_scopemappings_client(self, cid, client, rolereps, realm="master"): + """ Add client-level roles to the client's scope + + :param cid Client ID to grant + :param client Target client ID of the roles + :param rolereps Role representations of the target client + """ + client_scopemappings_client_url = URL_CLIENT_SCOPEMAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=cid, client=client) + try: + open_url(client_scopemappings_client_url, method="POST", headers=self.restheaders, + data=json.dumps(rolereps), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not assign roles from client %s to client %s, realm %s: %s" + % (client, cid, realm, str(e))) + + def remove_client_scopemappings_client(self, cid, client, rolereps, realm="master"): + """ Remove client-level roles from the client's scope. + + :param cid Client ID to grant + :param client Target client ID of the roles + :param rolereps Role representations of the target client + """ + client_scopemappings_client_url = URL_CLIENT_SCOPEMAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=cid, client=client) + try: + open_url(client_scopemappings_client_url, method="DELETE", headers=self.restheaders, + data=json.dumps(rolereps), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not remove assigned roles from client %s to client %s, realm %s: %s" + % (client, cid, realm, str(e))) + + def get_client_scopemappings_client(self, cid, client, realm="master"): + """ Fetch the roles associated with a client's scope, returns roles for the client. + + :param cid: ID of the client from which to obtain the scope mapping. + :param client: ID of the client associated to the role to grant. + :param realm: Realm from which to obtain the scope mapping. + :return: The scope mapping of specified client and client of the realm (default "master"). + """ + client_scopemappings_client_url = URL_CLIENT_SCOPEMAPPINGS_CLIENT.format( + url=self.baseurl, realm=realm, id=cid, client=client) + try: + return json.loads(to_native(open_url(client_scopemappings_client_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch scope mappings of the client %s for client %s, realm %s: %s" + % (cid, client, realm, str(e))) + + def get_client_scopemappings_client_available(self, cid, client, realm="master"): + """ Fetch available client-level roles, returns the roles for the client that can be associated with the client's scope + + :param cid: ID of the client from which to obtain the scope mapping. + :param client: ID of the client associated to the role to grant. + :param realm: Realm from which to obtain the scope mapping. + :return: The scope mapping of specified client and client of the realm (default "master"). + """ + client_scopemappings_client_available_url = URL_CLIENT_SCOPEMAPPINGS_CLIENT_AVAILABLE.format( + url=self.baseurl, realm=realm, id=cid, client=client) + try: + return json.loads(to_native(open_url(client_scopemappings_client_available_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch scope mappings of the client %s for client %s, realm %s: %s" + % (cid, client, realm, str(e))) + + def get_client_scopemappings_client_composite(self, cid, client, realm="master"): + """ Fetch effective client roles, returns the roles for the client that are associated with the client's scope. + + :param cid: ID of the client from which to obtain the scope mapping. + :param client: ID of the client associated to the role to grant. + :param realm: Realm from which to obtain the scope mapping. + :return: The scope mapping of specified client and client of the realm (default "master"). + """ + client_scopemappings_client_composite_url = URL_CLIENT_SCOPEMAPPINGS_CLIENT_COMPOSITE.format( + url=self.baseurl, realm=realm, id=cid, client=client) + try: + return json.loads(to_native(open_url(client_scopemappings_client_composite_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs).read())) + except Exception as e: + self.module.fail_json(msg="Could not fetch scope mappings of the client %s for client %s, realm %s: %s" + % (cid, client, realm, str(e))) + def add_group_rolemapping(self, gid, cid, role_rep, realm="master"): """ Fetch the composite role of a client in a specified goup on the Keycloak server. From e77093ff12442ac73a038ca744b7f45a5973ddec Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 17:44:02 +0800 Subject: [PATCH 02/17] docs(plugins/keycloak): update doc of get_client_roles_by_id --- plugins/module_utils/identity/keycloak/keycloak.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 373a2e0519f..db71d9591ee 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -428,7 +428,7 @@ def get_client_roles_by_id(self, cid, realm="master"): :param cid: ID of the client from which to obtain the rolemappings. :param realm: Realm from which to obtain the rolemappings. - :return: The rollemappings of specified group and client of the realm (default "master"). + :return: The rollemappings of specified client of the realm (default "master"). """ client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: From 642f859e6007bae526ddf9eec4dd308e23985ec4 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 17:46:47 +0800 Subject: [PATCH 03/17] feat(plugins/keycloak): add get_client_role_name_by_id util --- .../identity/keycloak/keycloak.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index db71d9591ee..337eef0981e 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -426,9 +426,9 @@ def delete_client(self, id, realm="master"): def get_client_roles_by_id(self, cid, realm="master"): """ Fetch the roles of the a client on the Keycloak server. - :param cid: ID of the client from which to obtain the rolemappings. - :param realm: Realm from which to obtain the rolemappings. - :return: The rollemappings of specified client of the realm (default "master"). + :param cid: ID of the client from which to obtain the roles. + :param realm: Realm from which to obtain roles. + :return: The roles of specified client of the realm (default "master"). """ client_roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) try: @@ -438,6 +438,20 @@ def get_client_roles_by_id(self, cid, realm="master"): self.module.fail_json(msg="Could not fetch rolemappings for client %s in realm %s: %s" % (cid, realm, str(e))) + def get_client_role_name_by_id(self, cid, rid, realm="master"): + """ Get the role name of a client. + + :param cid: ID of the client from which to obtain the roles. + :param rid: ID of the role. + :param realm: Realm from which to obtain the roles. + :return: The name of the role, None if not found. + """ + roles = self.get_client_roles_by_id(cid, realm=realm) + for role in roles: + if rid == role['id']: + return role['name'] + return None + def get_client_role_by_name(self, gid, cid, name, realm="master"): """ Get the role ID of a client. From 1526e33b2ba286a095a671911e6ee257c5ce6752 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 17:58:12 +0800 Subject: [PATCH 04/17] refactor(plugins/keycloak): rename get_client_role_by_name and remove unused argument --- .../module_utils/identity/keycloak/keycloak.py | 11 +++++------ .../keycloak/keycloak_client_rolemapping.py | 2 +- .../keycloak/test_keycloak_client_rolemapping.py | 16 ++++++++-------- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 337eef0981e..f70657929cf 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -452,17 +452,16 @@ def get_client_role_name_by_id(self, cid, rid, realm="master"): return role['name'] return None - def get_client_role_by_name(self, gid, cid, name, realm="master"): + def get_client_role_id_by_name(self, cid, name, realm="master"): """ Get the role ID of a client. - :param gid: ID of the group from which to obtain the rolemappings. - :param cid: ID of the client from which to obtain the rolemappings. + :param cid: ID of the client from which to obtain the roles. :param name: Name of the role. - :param realm: Realm from which to obtain the rolemappings. + :param realm: Realm from which to obtain the roles. :return: The ID of the role, None if not found. """ - rolemappings = self.get_client_roles_by_id(cid, realm=realm) - for role in rolemappings: + roles = self.get_client_roles_by_id(cid, realm=realm) + for role in roles: if name == role['name']: return role['id'] return None diff --git a/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py b/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py index b7cd70c122a..a4ba8b2897e 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_rolemapping.py @@ -277,7 +277,7 @@ def main(): 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_id = kc.get_client_role_by_name(gid, cid, role['name'], realm=realm) + role_id = kc.get_client_role_id_by_name(cid, role['name'], realm=realm) if role_id is not None: role['id'] = role_id else: diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py index 8e753bc6d0b..23b0d1f44bf 100644 --- a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_rolemapping.py @@ -20,7 +20,7 @@ @contextmanager -def patch_keycloak_api(get_group_by_name=None, get_client_id=None, get_client_role_by_name=None, +def patch_keycloak_api(get_group_by_name=None, get_client_id=None, get_client_role_id_by_name=None, get_client_rolemapping_by_id=None, get_client_available_rolemappings=None, get_client_composite_rolemappings=None, add_group_rolemapping=None, delete_group_rolemapping=None): @@ -43,8 +43,8 @@ def patch_keycloak_api(get_group_by_name=None, get_client_id=None, get_client_ro side_effect=get_group_by_name) as mock_get_group_by_name: with patch.object(obj, 'get_client_id', side_effect=get_client_id) as mock_get_client_id: - with patch.object(obj, 'get_client_role_by_name', - side_effect=get_client_role_by_name) as mock_get_client_role_by_name: + with patch.object(obj, 'get_client_role_id_by_name', + side_effect=get_client_role_id_by_name) as mock_get_client_role_by_name: with patch.object(obj, 'get_client_rolemapping_by_id', side_effect=get_client_rolemapping_by_id) as mock_get_client_rolemapping_by_id: with patch.object(obj, 'get_client_available_rolemappings', @@ -188,7 +188,7 @@ def test_map_clientrole_to_group_with_name(self): with mock_good_connection(): with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, - get_client_role_by_name=return_value_get_client_role_by_name, + get_client_role_id_by_name=return_value_get_client_role_by_name, get_client_available_rolemappings=return_value_get_client_available_rolemappings, get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, @@ -272,7 +272,7 @@ def test_map_clientrole_to_group_with_name_idempotency(self): with mock_good_connection(): with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, - get_client_role_by_name=return_value_get_client_role_by_name, + get_client_role_id_by_name=return_value_get_client_role_by_name, get_client_available_rolemappings=return_value_get_client_available_rolemappings, get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, @@ -374,7 +374,7 @@ def test_map_clientrole_to_group_with_id(self): with mock_good_connection(): with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, - get_client_role_by_name=return_value_get_client_role_by_name, + get_client_role_id_by_name=return_value_get_client_role_by_name, get_client_available_rolemappings=return_value_get_client_available_rolemappings, get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, @@ -461,7 +461,7 @@ def test_remove_clientrole_from_group(self): with mock_good_connection(): with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, - get_client_role_by_name=return_value_get_client_role_by_name, + get_client_role_id_by_name=return_value_get_client_role_by_name, get_client_available_rolemappings=return_value_get_client_available_rolemappings, get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, @@ -547,7 +547,7 @@ def test_remove_clientrole_from_group_idempotency(self): with mock_good_connection(): with patch_keycloak_api(get_group_by_name=return_value_get_group_by_name, get_client_id=return_value_get_client_id, - get_client_role_by_name=return_value_get_client_role_by_name, + get_client_role_id_by_name=return_value_get_client_role_by_name, get_client_available_rolemappings=return_value_get_client_available_rolemappings, get_client_composite_rolemappings=return_value_get_client_composite_rolemappings) \ as (mock_get_group_by_name, mock_get_client_id, mock_get_client_role_by_name, mock_add_group_rolemapping, From c501831173d5a6228048d6bb2bf76d588fa0f29d Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 18:05:28 +0800 Subject: [PATCH 05/17] feat(plugins/keycloak): add keycloak_client_scope_mapping module --- .../keycloak/keycloak_client_scopemapping.py | 333 ++++++++++++++++++ .../modules/keycloak_client_scopemapping.py | 1 + 2 files changed, 334 insertions(+) create mode 100644 plugins/modules/identity/keycloak/keycloak_client_scopemapping.py create mode 120000 plugins/modules/keycloak_client_scopemapping.py diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py new file mode 100644 index 00000000000..c2399f90b67 --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -0,0 +1,333 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# 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 + +DOCUMENTATION = ''' +--- +module: keycloak_client_scope_mapping + +short_description: Allows administration of Keycloak client-level roles of the client's scope via the Keycloak API + +version_added: 3.5.0 + +description: + - This module allows you to add, remove Keycloak client-level roles scope mapping 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/8.0/rest-api/index.html). + + - When updating a scope mapping, where possible provide the role ID to the module. This removes a lookup + to the API to translate the name into the role ID. + +options: + state: + description: + - State of the client-level role scope mapping. + - On C(present), the client-level role scope mapping will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the client-level role scope mapping 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' + client_id: + type: str + description: + - Name of the client to be mapped (different than I(cid)). + - This parameter is required (can be replaced by cid for less API call). + id: + type: str + description: + - ID of the client 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. + client_role: + description: Scope mapping of the client-level role + type: dict + suboptions: + id: + description: ID of the client + type: str + client_id: + description: Name of the client + type: str + roles: + description: Client roles + type: list + elements: dict + suboptions: + name: + description: Name of the role + type: str + id: + description: ID of the role + type: str + +extends_documentation_fragment: +- community.general.keycloak + + +author: + - Fynn Chen (@fynncfchen) +''' + +EXAMPLES = ''' +- name: Map a client role to a client, authentication with credentials + community.general.keycloak_client_scope_mapping: + 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 + client_id: client1 + client_role: + client_id: client2 + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Map a client role to a client, authentication with token + community.general.keycloak_client_scope_mapping: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + state: present + id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + client_role: + id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + roles: + - name: role_name1 + id: role_id1 + - name: role_name2 + id: role_id2 + delegate_to: localhost + +- name: Unmap client role from a client + community.general.keycloak_client_scope_mapping: + 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 + id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + client_role: + id: 52c4d786-b790-4570-9fc2-037aee19a2c2 + roles: + - name: role_name3 + id: role_id3 + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + sample: "Role role1 assigned to client client1." + +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, camel, \ + keycloak_argument_spec, get_token, KeycloakError, is_struct_included +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(default='master'), + client_id=dict(type='str'), + id=dict(type='str'), + client_role=dict( + type='dict', + options=dict( + client_id=dict(type='str'), + id=dict(type='str'), + roles=dict( + type='list', + elements='dict', + options=dict( + id=dict(type='str'), + name=dict(type='str') + ), + required_one_of=([['id', 'name']])), + ), + required_one_of=([['client_id', 'id']])), + ) + + 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'], + ['client_id', 'id'], + ]), + 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') + client_id = module.params.get('client_id') + cid = module.params.get('id') + client_role = module.params.get('client_role') + + # Get the potential missing parameters + if cid is None: + cid = kc.get_client_id(client_id, realm=realm) + if cid is None: + module.fail_json(msg='Could not fetch client %s:' % client_id) + + if not client_role: + module.exit_json(msg="Nothing to do (no roles specified).") + if client_role['id'] is None: + client_role['id'] = kc.get_client_id(client_role['client_id'], realm=realm) + if client_role['id'] is None: + module.fail_json(msg='Could not fetch client %s:' % client_role['client_id']) + if not client_role['roles']: + module.exit_json(msg="Nothing to do (no roles specified).") + for role in client_role['roles']: + if role['id'] is None: + role['id'] = kc.get_client_role_id_by_name(cid, role['name'], realm=realm) + if role['id'] is None: + module.fail_json(msg='Could not fetch role %s:' % (role['name'])) + if role['name'] is None: + role['name'] = kc.get_client_role_name_by_id(client_role['id'], role['id'], realm=realm) + if role['name'] is None: + module.fail_json(msg='Could not fetch role %s:' % (role['id'])) + + # Get effective client-level role mappings + available_roles_before = kc.get_client_scopemappings_client_available(cid, client_role['id'], realm=realm) + assigned_roles_before = kc.get_client_scopemappings_client_composite(cid, client_role['id'], realm=realm) + + result['existing'] = assigned_roles_before + result['proposed'] = client_role['roles'] + + update_roles = [] + + for role in client_role['roles']: + # Fetch roles to assign if state present + if state == 'present': + for available_role in available_roles_before: + if role['name'] == available_role['name']: + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + # Fetch roles to remove if state absent + else: + for assigned_role in assigned_roles_before: + if role['name'] == assigned_role['name']: + update_roles.append({ + 'id': role['id'], + 'name': role['name'], + }) + + if len(update_roles): + if state == 'present': + # Assign roles + result['changed'] = True + if module._diff: + result['diff'] = dict(before=assigned_roles_before, after=update_roles) + if module.check_mode: + module.exit_json(**result) + kc.add_client_scopemappings_client(cid, client_role['id'], update_roles, realm=realm) + result['msg'] = 'Roles %s from %s assigned to client %s.' % (update_roles, client_role['id'], cid) + assigned_roles_after = kc.get_client_scopemappings_client_composite(cid, client_role['id'], realm=realm) + result['end_state'] = assigned_roles_after + module.exit_json(**result) + else: + # Remove mapping of role + result['changed'] = True + if module._diff: + result['diff'] = dict(before=assigned_roles_before, after=update_roles) + if module.check_mode: + module.exit_json(**result) + kc.remove_client_scopemappings_client(cid, client_role['id'], update_roles, realm=realm) + result['msg'] = 'Roles %s from %s removed from client %s.' % (update_roles, client_role['id'], cid) + assigned_roles_after = kc.get_client_scopemappings_client_composite(cid, client_role['id'], realm=realm) + result['end_state'] = assigned_roles_after + module.exit_json(**result) + # Do nothing + else: + result['changed'] = False + result['msg'] = 'Nothing to do, roles %s are correctly mapped with client %s.' % (client_role['roles'], client_role['id']) + module.exit_json(**result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_client_scopemapping.py b/plugins/modules/keycloak_client_scopemapping.py new file mode 120000 index 00000000000..e9475396ffb --- /dev/null +++ b/plugins/modules/keycloak_client_scopemapping.py @@ -0,0 +1 @@ +./identity/keycloak/keycloak_client_scopemapping.py \ No newline at end of file From d536ef7582839a9855519f0ef937a59d47f8c110 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 18:11:18 +0800 Subject: [PATCH 06/17] fix(plugins/keycloak): fix module name document --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index c2399f90b67..772edbb4b63 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -8,7 +8,7 @@ DOCUMENTATION = ''' --- -module: keycloak_client_scope_mapping +module: keycloak_client_scopemapping short_description: Allows administration of Keycloak client-level roles of the client's scope via the Keycloak API From 1390020c1127fd42564850608b35c5efdd246f22 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 18:11:42 +0800 Subject: [PATCH 07/17] chore: add maintainer of keycloak_client_scopemapping module --- .github/BOTMETA.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 3e319e200df..fa485427abe 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -536,6 +536,8 @@ files: maintainers: Gaetan2907 $modules/identity/keycloak/keycloak_client_rolemapping.py: maintainers: Gaetan2907 + $modules/identity/keycloak/keycloak_client_scope_mapping.py: + maintainers: fynncfchen $modules/identity/keycloak/keycloak_group.py: maintainers: adamgoossens $modules/identity/keycloak/keycloak_identity_provider.py: @@ -1193,7 +1195,7 @@ files: maintainers: lekum $tests/a_module.py: maintainers: felixfontein -######################### + ######################### tests/: labels: tests tests/unit/: From 603802c0b7934f8c9c459437ab8f6fc625ed4026 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 9 Jan 2022 19:40:33 +0800 Subject: [PATCH 08/17] chore: fix typo on BOTMETA --- .github/BOTMETA.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index fa485427abe..eb5b566918c 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -536,7 +536,7 @@ files: maintainers: Gaetan2907 $modules/identity/keycloak/keycloak_client_rolemapping.py: maintainers: Gaetan2907 - $modules/identity/keycloak/keycloak_client_scope_mapping.py: + $modules/identity/keycloak/keycloak_client_scopemapping.py: maintainers: fynncfchen $modules/identity/keycloak/keycloak_group.py: maintainers: adamgoossens From 21aac615e50a4c3b7c3d69a8c02f0f56a04cba0f Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Tue, 11 Jan 2022 09:05:24 +0800 Subject: [PATCH 09/17] docs(plugins/keycloak): complete sentences --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index 772edbb4b63..f0939b09beb 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -55,7 +55,7 @@ - This parameter is not required for updating or deleting the rolemapping but providing it will reduce the number of API calls required. client_role: - description: Scope mapping of the client-level role + description: Scope mapping of the client-level role. type: dict suboptions: id: From c6f314e4c6148a46d9aaa08a4f2723f74c2bea8c Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Tue, 11 Jan 2022 17:15:38 +0800 Subject: [PATCH 10/17] feat(plugins/keycloak): add unit test --- .../test_keycloak_client_scopemapping.py | 555 ++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_scopemapping.py diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_scopemapping.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_scopemapping.py new file mode 100644 index 00000000000..65c20f7f4a9 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_client_scopemapping.py @@ -0,0 +1,555 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Ansible Project +# 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 + +from contextlib import contextmanager + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import call, patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules.identity.keycloak import keycloak_client_scopemapping + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_client_id=None, + get_client_role_id_by_name=None, + get_client_role_name_by_id=None, + get_client_scopemappings_client_available=None, + get_client_scopemappings_client_composite=None, + add_client_scopemappings_client=None, + remove_client_scopemappings_client=None): + """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server + + Patches the `login` and `_post_json` methods + + Keyword arguments are passed to the mock object that patches `_post_json` + + No arguments are passed to the mock object that patches `login` because no tests require it + + Example:: + + with patch_ipa(return_value={}) as (mock_login, mock_post): + ... + """ + + obj = keycloak_client_scopemapping.KeycloakAPI + with patch.object(obj, 'get_client_id', + side_effect=get_client_id) as mock_get_client_id: + with patch.object(obj, 'get_client_role_id_by_name', + side_effect=get_client_role_id_by_name) as mock_get_client_role_id_by_name: + with patch.object(obj, 'get_client_role_name_by_id', + side_effect=get_client_role_name_by_id) as mock_get_client_role_name_by_id: + with patch.object(obj, 'get_client_scopemappings_client_available', + side_effect=get_client_scopemappings_client_available) as mock_get_client_scopemappings_client_available: + with patch.object(obj, 'get_client_scopemappings_client_composite', + side_effect=get_client_scopemappings_client_composite) as mock_get_client_scopemappings_client_composite: + with patch.object(obj, 'add_client_scopemappings_client', + side_effect=add_client_scopemappings_client) as mock_add_client_scopemappings_client: + with patch.object(obj, 'remove_client_scopemappings_client', + side_effect=remove_client_scopemappings_client) as mock_remove_client_scopemappings_client: + yield mock_get_client_id, \ + mock_get_client_role_id_by_name, \ + mock_get_client_role_name_by_id, \ + mock_get_client_scopemappings_client_available, \ + mock_get_client_scopemappings_client_composite, \ + mock_add_client_scopemappings_client, \ + mock_remove_client_scopemappings_client + + +def get_response(object_with_future_response, method, get_id_call_count): + if callable(object_with_future_response): + return object_with_future_response() + if isinstance(object_with_future_response, dict): + return get_response( + object_with_future_response[method], method, get_id_call_count) + if isinstance(object_with_future_response, list): + call_number = next(get_id_call_count) + return get_response( + object_with_future_response[call_number], method, get_id_call_count) + return object_with_future_response + + +def build_mocked_request(get_id_user_count, response_dict): + def _mocked_requests(*args, **kwargs): + url = args[0] + method = kwargs['method'] + future_response = response_dict.get(url, None) + return get_response(future_response, method, get_id_user_count) + return _mocked_requests + + +def create_wrapper(text_as_string): + """Allow to mock many times a call to one address. + Without this function, the StringIO is empty for the second call. + """ + def _create_wrapper(): + return StringIO(text_as_string) + return _create_wrapper + + +def mock_good_connection(): + token_response = { + 'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), } + return patch( + 'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url', + side_effect=build_mocked_request(count(), token_response), + autospec=True + ) + + +class TestKeycloakClientScopeMapping(ModuleTestCase): + def setUp(self): + super(TestKeycloakClientScopeMapping, self).setUp() + self.module = keycloak_client_scopemapping + + def test_add_role_with_name(self): + """Map role name to id and name, add one role""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'client_id': 'client_to_grant', # c0f8490c-b224-4737-a567-20223e4c1727 + 'client_role': { + 'client_id': 'client_with_roles', # 27112a16-c847-4def-9140-2b97a1f4108a + 'roles': [{ + 'name': 'test_role2', # c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d + }], + }, + } + + return_value_get_client_id = [ + 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + ] + return_value_get_client_role_id_by_name = [ + '00a2d9a9-924e-49fa-8cde-c539c010ef6e' # test_role1 + ] + return_value_get_client_role_name_by_id = [ + 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + ] + return_value_get_client_scopemappings_client_available = [ + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }, { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }] + ] + return_value_get_client_scopemappings_client_composite = [ + [], # Before + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }] # After + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_id=return_value_get_client_id, + get_client_role_id_by_name=return_value_get_client_role_id_by_name, + get_client_role_name_by_id=return_value_get_client_role_name_by_id, + get_client_scopemappings_client_available=return_value_get_client_scopemappings_client_available, + get_client_scopemappings_client_composite=return_value_get_client_scopemappings_client_composite)\ + as (mock_get_client_id, + mock_get_client_role_id_by_name, + mock_get_client_role_name_by_id, + mock_get_client_scopemappings_client_available, + mock_get_client_scopemappings_client_composite, + mock_add_client_scopemappings_client, + mock_remove_client_scopemappings_client): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_client_id.call_count, 2) + self.assertEqual(mock_get_client_role_id_by_name.call_count, 1) + self.assertEqual(mock_get_client_role_name_by_id.call_count, 0) + self.assertEqual(mock_get_client_scopemappings_client_available.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_composite.call_count, 2) + self.assertEqual(mock_add_client_scopemappings_client.call_count, 1) + self.assertEqual(mock_remove_client_scopemappings_client.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_add_role_with_name_idempotency(self): + """Map role name to id and name, no change""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'client_id': 'client_to_grant', # c0f8490c-b224-4737-a567-20223e4c1727 + 'client_role': { + 'client_id': 'client_with_roles', # 27112a16-c847-4def-9140-2b97a1f4108a + 'roles': [{ + 'name': 'test_role1', # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + }, { + 'name': 'test_role2', # c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d + }], + }, + } + + return_value_get_client_id = [ + 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + ] + return_value_get_client_role_id_by_name = [ + '00a2d9a9-924e-49fa-8cde-c539c010ef6e', # test_role1 + 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', # test_role2 + ] + return_value_get_client_role_name_by_id = [ + 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + ] + return_value_get_client_scopemappings_client_available = [ + [], # Empty, all available roles are already granted + ] + return_value_get_client_scopemappings_client_composite = [ + [], # Before + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }, { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }]] # After + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_id=return_value_get_client_id, + get_client_role_id_by_name=return_value_get_client_role_id_by_name, + get_client_role_name_by_id=return_value_get_client_role_name_by_id, + get_client_scopemappings_client_available=return_value_get_client_scopemappings_client_available, + get_client_scopemappings_client_composite=return_value_get_client_scopemappings_client_composite)\ + as (mock_get_client_id, + mock_get_client_role_id_by_name, + mock_get_client_role_name_by_id, + mock_get_client_scopemappings_client_available, + mock_get_client_scopemappings_client_composite, + mock_add_client_scopemappings_client, + mock_remove_client_scopemappings_client): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_client_id.call_count, 2) + self.assertEqual(mock_get_client_role_id_by_name.call_count, 2) + self.assertEqual(mock_get_client_role_name_by_id.call_count, 0) + self.assertEqual(mock_get_client_scopemappings_client_available.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_composite.call_count, 1) + self.assertEqual(mock_add_client_scopemappings_client.call_count, 0) + self.assertEqual(mock_remove_client_scopemappings_client.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_add_role_with_id(self): + """Add one role with ID""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'present', + 'id': 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + 'client_role': { + 'id': '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + 'roles': [{ + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', # test_role1 + }], + }, + } + return_value_get_client_id = [ + 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + ] + return_value_get_client_role_id_by_name = [ + '00a2d9a9-924e-49fa-8cde-c539c010ef6e' # test_role1 + ] + return_value_get_client_role_name_by_id = [ + 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + ] + return_value_get_client_scopemappings_client_available = [ + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }, { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }] + ] + return_value_get_client_scopemappings_client_composite = [ + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }], # Before + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }, { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }] # After + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_id=return_value_get_client_id, + get_client_role_id_by_name=return_value_get_client_role_id_by_name, + get_client_role_name_by_id=return_value_get_client_role_name_by_id, + get_client_scopemappings_client_available=return_value_get_client_scopemappings_client_available, + get_client_scopemappings_client_composite=return_value_get_client_scopemappings_client_composite)\ + as (mock_get_client_id, + mock_get_client_role_id_by_name, + mock_get_client_role_name_by_id, + mock_get_client_scopemappings_client_available, + mock_get_client_scopemappings_client_composite, + mock_add_client_scopemappings_client, + mock_remove_client_scopemappings_client): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_client_id.call_count, 0) + self.assertEqual(mock_get_client_role_id_by_name.call_count, 0) + self.assertEqual(mock_get_client_role_name_by_id.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_available.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_composite.call_count, 2) + self.assertEqual(mock_add_client_scopemappings_client.call_count, 1) + self.assertEqual(mock_remove_client_scopemappings_client.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_remove_role(self): + """Remove a role by client ID and role name""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'absent', + 'id': 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + 'client_role': { + 'id': '52c4d786-b790-4570-9fc2-037aee19a2c2', # client_with_roles + 'roles': [{ + 'name': 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + }], + }, + } + return_value_get_client_id = [ + 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + ] + return_value_get_client_role_id_by_name = [ + '00a2d9a9-924e-49fa-8cde-c539c010ef6e' # test_role1 + ] + return_value_get_client_role_name_by_id = [ + 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + ] + return_value_get_client_scopemappings_client_available = [ + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }], + ] + return_value_get_client_scopemappings_client_composite = [ + [{ + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + }], # Before + [] # After + ] + + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_id=return_value_get_client_id, + get_client_role_id_by_name=return_value_get_client_role_id_by_name, + get_client_role_name_by_id=return_value_get_client_role_name_by_id, + get_client_scopemappings_client_available=return_value_get_client_scopemappings_client_available, + get_client_scopemappings_client_composite=return_value_get_client_scopemappings_client_composite)\ + as (mock_get_client_id, + mock_get_client_role_id_by_name, + mock_get_client_role_name_by_id, + mock_get_client_scopemappings_client_available, + mock_get_client_scopemappings_client_composite, + mock_add_client_scopemappings_client, + mock_remove_client_scopemappings_client): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_client_id.call_count, 0) + self.assertEqual(mock_get_client_role_id_by_name.call_count, 1) + self.assertEqual(mock_get_client_role_name_by_id.call_count, 0) + self.assertEqual(mock_get_client_scopemappings_client_available.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_composite.call_count, 2) + self.assertEqual(mock_add_client_scopemappings_client.call_count, 0) + self.assertEqual(mock_remove_client_scopemappings_client.call_count, 1) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + def test_remove_role_idempotency(self): + """Remove role, no change""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'realm': 'realm-name', + 'state': 'absent', + 'id': 'c0f8490c-b224-4737-a567-20223e4c1727', # client_to_grant + 'client_role': { + 'client_id': 'client_with_roles', # 52c4d786-b790-4570-9fc2-037aee19a2c2 + 'roles': [{ + 'name': 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + }], + }, + } + return_value_get_client_id = [ + '27112a16-c847-4def-9140-2b97a1f4108a', # client_with_roles + ] + return_value_get_client_role_id_by_name = [ + '00a2d9a9-924e-49fa-8cde-c539c010ef6e' # test_role1 + ] + return_value_get_client_role_name_by_id = [ + 'test_role1' # 00a2d9a9-924e-49fa-8cde-c539c010ef6e + ] + return_value_get_client_scopemappings_client_available = [ + [ + { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': 'c2bf2edb-da94-4f2f-b9f2-196dfee3fe4d', + 'name': 'test_role2' + }, + { + 'clientRole': 'true', + 'composite': 'false', + 'containerId': 'c0f8490c-b224-4737-a567-20223e4c1727', + 'id': '00a2d9a9-924e-49fa-8cde-c539c010ef6e', + 'name': 'test_role1' + } + ], + ] + return_value_get_client_scopemappings_client_composite = [ + [], + ] + + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_client_id=return_value_get_client_id, + get_client_role_id_by_name=return_value_get_client_role_id_by_name, + get_client_role_name_by_id=return_value_get_client_role_name_by_id, + get_client_scopemappings_client_available=return_value_get_client_scopemappings_client_available, + get_client_scopemappings_client_composite=return_value_get_client_scopemappings_client_composite)\ + as (mock_get_client_id, + mock_get_client_role_id_by_name, + mock_get_client_role_name_by_id, + mock_get_client_scopemappings_client_available, + mock_get_client_scopemappings_client_composite, + mock_add_client_scopemappings_client, + mock_remove_client_scopemappings_client): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_client_id.call_count, 1) + self.assertEqual(mock_get_client_role_id_by_name.call_count, 1) + self.assertEqual(mock_get_client_role_name_by_id.call_count, 0) + self.assertEqual(mock_get_client_scopemappings_client_available.call_count, 1) + self.assertEqual(mock_get_client_scopemappings_client_composite.call_count, 1) + self.assertEqual(mock_add_client_scopemappings_client.call_count, 0) + self.assertEqual(mock_remove_client_scopemappings_client.call_count, 0) + + # Verify that the module's changed status matches what is expected + self.assertIs(exec_info.exception.args[0]['changed'], changed) + + +if __name__ == '__main__': + unittest.main() From 96f383f9490824aa4288407d907b410c0c872211 Mon Sep 17 00:00:00 2001 From: Fynnnnn Date: Sat, 22 Jan 2022 15:22:23 +0800 Subject: [PATCH 11/17] Update plugins/modules/identity/keycloak/keycloak_client_scopemapping.py Co-authored-by: Felix Fontein --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index f0939b09beb..fe0ec3fac7c 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -12,7 +12,7 @@ short_description: Allows administration of Keycloak client-level roles of the client's scope via the Keycloak API -version_added: 3.5.0 +version_added: 4.4.0 description: - This module allows you to add, remove Keycloak client-level roles scope mapping with the Keycloak REST API. From 01c0496e7a2493da6983524486eb36bd536583a9 Mon Sep 17 00:00:00 2001 From: Fynnnnn Date: Sat, 22 Jan 2022 15:22:35 +0800 Subject: [PATCH 12/17] Update plugins/modules/identity/keycloak/keycloak_client_scopemapping.py Co-authored-by: Felix Fontein --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index fe0ec3fac7c..e827ca245a3 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -10,7 +10,7 @@ --- module: keycloak_client_scopemapping -short_description: Allows administration of Keycloak client-level roles of the client's scope via the Keycloak API +short_description: Administration of Keycloak client-level roles of the client's scope via the Keycloak API version_added: 4.4.0 From 25cd360ed00072a32e517441b042106ab7a60766 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Sun, 23 Jan 2022 08:52:28 +0800 Subject: [PATCH 13/17] docs(plugins/keycloak): update description --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index e827ca245a3..2c60a49393e 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -85,7 +85,7 @@ ''' EXAMPLES = ''' -- name: Map a client role to a client, authentication with credentials +- name: Map several client roles to a client, authentication with credentials community.general.keycloak_client_scope_mapping: realm: MyCustomRealm auth_client_id: admin-cli From b0cac1551bdcd7aa99f72a2825535b9574336e47 Mon Sep 17 00:00:00 2001 From: Fynnnnn Date: Mon, 24 Jan 2022 17:54:33 +0800 Subject: [PATCH 14/17] Update plugins/modules/identity/keycloak/keycloak_client_scopemapping.py Co-authored-by: Pierre Dumuid --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index 2c60a49393e..c6402f1441d 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -122,6 +122,7 @@ delegate_to: localhost - name: Unmap client role from a client +- name: Remove a single client role map from a client (leaves the other roles in place) community.general.keycloak_client_scope_mapping: realm: MyCustomRealm auth_client_id: admin-cli From f2982ffd1c49cdf48d80cb7a44f8d7ba33cafa62 Mon Sep 17 00:00:00 2001 From: Fynnnnn Date: Mon, 24 Jan 2022 17:55:01 +0800 Subject: [PATCH 15/17] Update plugins/modules/identity/keycloak/keycloak_client_scopemapping.py Co-authored-by: Felix Fontein --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index c6402f1441d..e4949e3aa64 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -59,7 +59,7 @@ type: dict suboptions: id: - description: ID of the client + description: ID of the client. type: str client_id: description: Name of the client From c16a23d5c7938c13116c126f4b1cf54526f4a611 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Mon, 24 Jan 2022 17:58:19 +0800 Subject: [PATCH 16/17] docs(plugins/keycloak): update IDs --- .../identity/keycloak/keycloak_client_scopemapping.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index e4949e3aa64..56efc8d3ad2 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -111,9 +111,9 @@ auth_keycloak_url: https://auth.example.com/auth token: TOKEN state: present - id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + id: c0f8490c-b224-4737-a567-20223e4c1727 client_role: - id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + id: 27112a16-c847-4def-9140-2b97a1f4108a roles: - name: role_name1 id: role_id1 @@ -131,9 +131,9 @@ auth_username: USERNAME auth_password: PASSWORD state: absent - id: 6e1d65de-f01a-4d3a-b7c6-3d581165966f + id: c0f8490c-b224-4737-a567-20223e4c1727 client_role: - id: 52c4d786-b790-4570-9fc2-037aee19a2c2 + id: 27112a16-c847-4def-9140-2b97a1f4108a roles: - name: role_name3 id: role_id3 From 813dd159a083723f928858a336dfcff03ee3f7d8 Mon Sep 17 00:00:00 2001 From: fynncfchen Date: Mon, 24 Jan 2022 17:58:57 +0800 Subject: [PATCH 17/17] chore: remove duplicate line --- .../modules/identity/keycloak/keycloak_client_scopemapping.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py index 56efc8d3ad2..53ea5a1dca1 100644 --- a/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py +++ b/plugins/modules/identity/keycloak/keycloak_client_scopemapping.py @@ -121,7 +121,6 @@ id: role_id2 delegate_to: localhost -- name: Unmap client role from a client - name: Remove a single client role map from a client (leaves the other roles in place) community.general.keycloak_client_scope_mapping: realm: MyCustomRealm