1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

Inspq keycloak role composites (#6469)

* Add composites to keycloak_role module

* Add composites support for realm role in keycloak module_utils

* Clean f.write from keycloak_role module

* keycloak_role support state for realm role composites

* Add support for composites in client role for keycloak_role module

* Add changelog fragment for keycloak role composites PR

* Fix pep8 and validate-modules tests errors

* Update changelogs/fragments/6469-add-composites-support-for-keycloak-role.yml

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

I will try it

Co-authored-by: Felix Fontein <felix@fontein.de>

* Fix test_keycloak_role assertion

* Fix role composite compare before update in keycloak_role module

* Fix realm problem with update_role_composites in keycloak.py module_utils

* Add units tests for composites and client roles in keycloak_role module

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Change try in is_struct_included and add unit tests for keycloak.py module_utils

* Add integration tests for composites roles and fix bug with non master roles in keycloak_role module

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/modules/keycloak_role.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* Update plugins/module_utils/identity/keycloak/keycloak.py

Co-authored-by: Felix Fontein <felix@fontein.de>

* is_struct_included refactor

---------

Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
Philippe Gauthier 2023-06-15 00:57:30 -04:00 committed by GitHub
parent 9f47cdde32
commit 9395df1c6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 860 additions and 17 deletions

View file

@ -0,0 +1,2 @@
minor_changes:
- keycloak_role - add composite roles support for realm and client roles (https://github.com/ansible-collections/community.general/pull/6469).

View file

@ -1680,6 +1680,9 @@ class KeycloakAPI(object):
""" """
roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm) roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm)
try: try:
if "composites" in rolerep:
keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"])
rolerep["composites"] = keycloak_compatible_composites
return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(rolerep), validate_certs=self.validate_certs) data=json.dumps(rolerep), validate_certs=self.validate_certs)
except Exception as e: except Exception as e:
@ -1694,12 +1697,124 @@ class KeycloakAPI(object):
""" """
role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name'])) role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name']))
try: try:
return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, composites = None
if "composites" in rolerep:
composites = copy.deepcopy(rolerep["composites"])
del rolerep["composites"]
role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(rolerep), validate_certs=self.validate_certs) data=json.dumps(rolerep), validate_certs=self.validate_certs)
if composites is not None:
self.update_role_composites(rolerep=rolerep, composites=composites, realm=realm)
return role_response
except Exception as e: except Exception as e:
self.module.fail_json(msg='Could not update role %s in realm %s: %s' self.module.fail_json(msg='Could not update role %s in realm %s: %s'
% (rolerep['name'], realm, str(e))) % (rolerep['name'], realm, str(e)))
def get_role_composites(self, rolerep, clientid=None, realm='master'):
composite_url = ''
try:
if clientid is not None:
client = self.get_client_by_clientid(client_id=clientid, realm=realm)
cid = client['id']
composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"]))
else:
composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"]))
# Get existing composites
return json.loads(to_native(open_url(
composite_url,
method='GET',
http_agent=self.http_agent,
headers=self.restheaders,
timeout=self.connection_timeout,
validate_certs=self.validate_certs).read()))
except Exception as e:
self.module.fail_json(msg='Could not get role %s composites in realm %s: %s'
% (rolerep['name'], realm, str(e)))
def create_role_composites(self, rolerep, composites, clientid=None, realm='master'):
composite_url = ''
try:
if clientid is not None:
client = self.get_client_by_clientid(client_id=clientid, realm=realm)
cid = client['id']
composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"]))
else:
composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"]))
# Get existing composites
# create new composites
return open_url(composite_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(composites), validate_certs=self.validate_certs)
except Exception as e:
self.module.fail_json(msg='Could not create role %s composites in realm %s: %s'
% (rolerep['name'], realm, str(e)))
def delete_role_composites(self, rolerep, composites, clientid=None, realm='master'):
composite_url = ''
try:
if clientid is not None:
client = self.get_client_by_clientid(client_id=clientid, realm=realm)
cid = client['id']
composite_url = URL_CLIENT_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep["name"]))
else:
composite_url = URL_REALM_ROLE_COMPOSITES.format(url=self.baseurl, realm=realm, name=quote(rolerep["name"]))
# Get existing composites
# create new composites
return open_url(composite_url, method='DELETE', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(composites), validate_certs=self.validate_certs)
except Exception as e:
self.module.fail_json(msg='Could not create role %s composites in realm %s: %s'
% (rolerep['name'], realm, str(e)))
def update_role_composites(self, rolerep, composites, clientid=None, realm='master'):
# Get existing composites
existing_composites = self.get_role_composites(rolerep=rolerep, clientid=clientid, realm=realm)
composites_to_be_created = []
composites_to_be_deleted = []
for composite in composites:
composite_found = False
existing_composite_client = None
for existing_composite in existing_composites:
if existing_composite["clientRole"]:
existing_composite_client = self.get_client_by_id(existing_composite["containerId"], realm=realm)
if ("client_id" in composite
and composite['client_id'] is not None
and existing_composite_client["clientId"] == composite["client_id"]
and composite["name"] == existing_composite["name"]):
composite_found = True
break
else:
if (("client_id" not in composite or composite['client_id'] is None)
and composite["name"] == existing_composite["name"]):
composite_found = True
break
if (not composite_found and ('state' not in composite or composite['state'] == 'present')):
if "client_id" in composite and composite['client_id'] is not None:
client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm)
for client_role in client_roles:
if client_role['name'] == composite['name']:
composites_to_be_created.append(client_role)
break
else:
realm_role = self.get_realm_role(name=composite["name"], realm=realm)
composites_to_be_created.append(realm_role)
elif composite_found and 'state' in composite and composite['state'] == 'absent':
if "client_id" in composite and composite['client_id'] is not None:
client_roles = self.get_client_roles(clientid=composite['client_id'], realm=realm)
for client_role in client_roles:
if client_role['name'] == composite['name']:
composites_to_be_deleted.append(client_role)
break
else:
realm_role = self.get_realm_role(name=composite["name"], realm=realm)
composites_to_be_deleted.append(realm_role)
if len(composites_to_be_created) > 0:
# create new composites
self.create_role_composites(rolerep=rolerep, composites=composites_to_be_created, clientid=clientid, realm=realm)
if len(composites_to_be_deleted) > 0:
# delete new composites
self.delete_role_composites(rolerep=rolerep, composites=composites_to_be_deleted, clientid=clientid, realm=realm)
def delete_realm_role(self, name, realm='master'): def delete_realm_role(self, name, realm='master'):
""" Delete a realm role. """ Delete a realm role.
@ -1778,12 +1893,30 @@ class KeycloakAPI(object):
% (clientid, realm)) % (clientid, realm))
roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid) roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid)
try: try:
if "composites" in rolerep:
keycloak_compatible_composites = self.convert_role_composites(rolerep["composites"])
rolerep["composites"] = keycloak_compatible_composites
return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, return open_url(roles_url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(rolerep), validate_certs=self.validate_certs) data=json.dumps(rolerep), validate_certs=self.validate_certs)
except Exception as e: except Exception as e:
self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s' self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s'
% (rolerep['name'], clientid, realm, str(e))) % (rolerep['name'], clientid, realm, str(e)))
def convert_role_composites(self, composites):
keycloak_compatible_composites = {
'client': {},
'realm': []
}
for composite in composites:
if 'state' not in composite or composite['state'] == 'present':
if "client_id" in composite and composite["client_id"] is not None:
if composite["client_id"] not in keycloak_compatible_composites["client"]:
keycloak_compatible_composites["client"][composite["client_id"]] = []
keycloak_compatible_composites["client"][composite["client_id"]].append(composite["name"])
else:
keycloak_compatible_composites["realm"].append(composite["name"])
return keycloak_compatible_composites
def update_client_role(self, rolerep, clientid, realm="master"): def update_client_role(self, rolerep, clientid, realm="master"):
""" Update an existing client role. """ Update an existing client role.
@ -1798,8 +1931,15 @@ class KeycloakAPI(object):
% (clientid, realm)) % (clientid, realm))
role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name'])) role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name']))
try: try:
return open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout, composites = None
if "composites" in rolerep:
composites = copy.deepcopy(rolerep["composites"])
del rolerep['composites']
update_role_response = open_url(role_url, method='PUT', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(rolerep), validate_certs=self.validate_certs) data=json.dumps(rolerep), validate_certs=self.validate_certs)
if composites is not None:
self.update_role_composites(rolerep=rolerep, clientid=clientid, composites=composites, realm=realm)
return update_role_response
except Exception as e: except Exception as e:
self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s' self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s'
% (rolerep['name'], clientid, realm, str(e))) % (rolerep['name'], clientid, realm, str(e)))

View file

@ -77,6 +77,42 @@ options:
description: description:
- A dict of key/value pairs to set as custom attributes for the role. - A dict of key/value pairs to set as custom attributes for the role.
- Values may be single values (e.g. a string) or a list of strings. - Values may be single values (e.g. a string) or a list of strings.
composite:
description:
- If V(true), the role is a composition of other realm and/or client role.
default: false
type: bool
version_added: 7.1.0
composites:
description:
- List of roles to include to the composite realm role.
- If the composite role is a client role, the C(clientId) (not ID of the client) must be specified.
default: []
type: list
elements: dict
version_added: 7.1.0
suboptions:
name:
description:
- Name of the role. This can be the name of a REALM role or a client role.
type: str
required: true
client_id:
description:
- Client ID if the role is a client role. Do not include this option for a REALM role.
- Use the client ID you can see in the Keycloak console, not the technical ID of the client.
type: str
required: false
aliases:
- clientId
state:
description:
- Create the composite if present, remove it if absent.
type: str
choices:
- present
- absent
default: present
extends_documentation_fragment: extends_documentation_fragment:
- community.general.keycloak - community.general.keycloak
@ -198,8 +234,9 @@ end_state:
''' '''
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
keycloak_argument_spec, get_token, KeycloakError keycloak_argument_spec, get_token, KeycloakError, is_struct_included
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
import copy
def main(): def main():
@ -210,6 +247,12 @@ def main():
""" """
argument_spec = keycloak_argument_spec() argument_spec = keycloak_argument_spec()
composites_spec = dict(
name=dict(type='str', required=True),
client_id=dict(type='str', aliases=['clientId'], required=False),
state=dict(type='str', default='present', choices=['present', 'absent'])
)
meta_args = dict( meta_args = dict(
state=dict(type='str', default='present', choices=['present', 'absent']), state=dict(type='str', default='present', choices=['present', 'absent']),
name=dict(type='str', required=True), name=dict(type='str', required=True),
@ -217,6 +260,8 @@ def main():
realm=dict(type='str', default='master'), realm=dict(type='str', default='master'),
client_id=dict(type='str'), client_id=dict(type='str'),
attributes=dict(type='dict'), attributes=dict(type='dict'),
composites=dict(type='list', default=[], options=composites_spec, elements='dict'),
composite=dict(type='bool', default=False),
) )
argument_spec.update(meta_args) argument_spec.update(meta_args)
@ -250,7 +295,7 @@ def main():
# Filter and map the parameters names that apply to the role # Filter and map the parameters names that apply to the role
role_params = [x for x in module.params role_params = [x for x in module.params
if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id', 'composites'] and if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'client_id'] and
module.params.get(x) is not None] module.params.get(x) is not None]
# See if it already exists in Keycloak # See if it already exists in Keycloak
@ -269,10 +314,10 @@ def main():
new_param_value = module.params.get(param) new_param_value = module.params.get(param)
old_value = before_role[param] if param in before_role else None old_value = before_role[param] if param in before_role else None
if new_param_value != old_value: if new_param_value != old_value:
changeset[camel(param)] = new_param_value changeset[camel(param)] = copy.deepcopy(new_param_value)
# Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis)
desired_role = before_role.copy() desired_role = copy.deepcopy(before_role)
desired_role.update(changeset) desired_role.update(changeset)
result['proposed'] = changeset result['proposed'] = changeset
@ -309,6 +354,9 @@ def main():
kc.create_client_role(desired_role, clientid, realm) kc.create_client_role(desired_role, clientid, realm)
after_role = kc.get_client_role(name, clientid, realm) after_role = kc.get_client_role(name, clientid, realm)
if after_role['composite']:
after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm)
result['end_state'] = after_role result['end_state'] = after_role
result['msg'] = 'Role {name} has been created'.format(name=name) result['msg'] = 'Role {name} has been created'.format(name=name)
@ -316,10 +364,25 @@ def main():
else: else:
if state == 'present': if state == 'present':
compare_exclude = []
if 'composites' in desired_role and isinstance(desired_role['composites'], list) and len(desired_role['composites']) > 0:
composites = kc.get_role_composites(rolerep=before_role, clientid=clientid, realm=realm)
before_role['composites'] = []
for composite in composites:
before_composite = {}
if composite['clientRole']:
composite_client = kc.get_client_by_id(id=composite['containerId'], realm=realm)
before_composite['client_id'] = composite_client['clientId']
else:
before_composite['client_id'] = None
before_composite['name'] = composite['name']
before_composite['state'] = 'present'
before_role['composites'].append(before_composite)
else:
compare_exclude.append('composites')
# Process an update # Process an update
# no changes # no changes
if desired_role == before_role: if is_struct_included(desired_role, before_role, exclude=compare_exclude):
result['changed'] = False result['changed'] = False
result['end_state'] = desired_role result['end_state'] = desired_role
result['msg'] = "No changes required to role {name}.".format(name=name) result['msg'] = "No changes required to role {name}.".format(name=name)
@ -341,6 +404,8 @@ def main():
else: else:
kc.update_client_role(desired_role, clientid, realm) kc.update_client_role(desired_role, clientid, realm)
after_role = kc.get_client_role(name, clientid, realm) after_role = kc.get_client_role(name, clientid, realm)
if after_role['composite']:
after_role['composites'] = kc.get_role_composites(rolerep=after_role, clientid=clientid, realm=realm)
result['end_state'] = after_role result['end_state'] = after_role

View file

@ -0,0 +1,20 @@
<!--
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
-->
# Running keycloak_user module integration test
To run Keycloak user module's integration test, start a keycloak server using Docker or Podman:
podman|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
Source Ansible env-setup from ansible github repository
Run integration tests:
ansible-test integration keycloak_role --python 3.10 --allow-unsupported
Cleanup:
podman|docker stop mykeycloak

View file

@ -248,3 +248,236 @@
that: that:
- result is not changed - result is not changed
- result.end_state == {} - result.end_state == {}
- name: Create realm role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert realm role is created with composites
assert:
that:
- result is changed
- result.end_state.composites | length == 3
- name: Change realm role with composites no change
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert realm role with composites have not changed
assert:
that:
- result is not changed
- result.end_state.composites | length == 3
- name: Remove composite from realm role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites_with_absent }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert composite was removed from realm role with composites
assert:
that:
- result is changed
- result.end_state.composites | length == 2
- name: Delete realm role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ keycloak_role_name }}"
state: absent
register: result
- name: Debug
debug:
var: result
- name: Assert realm role deleted
assert:
that:
- result is changed
- result.end_state == {}
- name: Delete absent realm role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ keycloak_role_name }}"
state: absent
register: result
- name: Debug
debug:
var: result
- name: Assert not changed and realm role absent
assert:
that:
- result is not changed
- result.end_state == {}
- name: Create client role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
client_id: "{{ client_id }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert client role is created with composites
assert:
that:
- result is changed
- result.end_state.composites | length == 3
- name: Change client role with composites no change
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
client_id: "{{ client_id }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert client role with composites have not changed
assert:
that:
- result is not changed
- result.end_state.composites | length == 3
- name: Remove composite from client role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
name: "{{ keycloak_role_name }}"
client_id: "{{ client_id }}"
realm: "{{ realm }}"
description: "{{ keycloak_role_description }}"
composite: "{{ keycloak_role_composite }}"
composites: "{{ keycloak_role_composites_with_absent }}"
state: present
register: result
- name: Debug
debug:
var: result
- name: Assert composite was removed from client role with composites
assert:
that:
- result is changed
- result.end_state.composites | length == 2
- name: Delete client role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ keycloak_role_name }}"
client_id: "{{ client_id }}"
state: absent
register: result
- name: Debug
debug:
var: result
- name: Assert client role deleted
assert:
that:
- result is changed
- result.end_state == {}
- name: Delete absent client role with composites
community.general.keycloak_role:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
name: "{{ keycloak_role_name }}"
client_id: "{{ client_id }}"
state: absent
register: result
- name: Debug
debug:
var: result
- name: Assert not changed and client role absent
assert:
that:
- result is not changed
- result.end_state == {}

View file

@ -12,3 +12,30 @@ client_id: myclient
role: myrole role: myrole
description_1: desc 1 description_1: desc 1
description_2: desc 2 description_2: desc 2
keycloak_role_name: test
keycloak_role_description: test
keycloak_role_composite: true
keycloak_role_composites:
- name: view-clients
client_id: "realm-management"
state: present
- name: query-clients
client_id: "realm-management"
state: present
- name: offline_access
state: present
keycloak_client_id: test-client
keycloak_client_name: test-client
keycloak_client_description: This is a client for testing purpose
role_state: present
keycloak_role_composites_with_absent:
- name: view-clients
client_id: "realm-management"
state: present
- name: query-clients
client_id: "realm-management"
state: present
- name: offline_access
state: absent

View file

@ -21,7 +21,9 @@ from ansible.module_utils.six import StringIO
@contextmanager @contextmanager
def patch_keycloak_api(get_realm_role, create_realm_role=None, update_realm_role=None, delete_realm_role=None): def patch_keycloak_api(get_realm_role=None, create_realm_role=None, update_realm_role=None, delete_realm_role=None,
get_client_role=None, create_client_role=None, update_client_role=None, delete_client_role=None,
get_client_by_id=None, get_role_composites=None):
"""Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server
Patches the `login` and `_post_json` methods Patches the `login` and `_post_json` methods
@ -41,7 +43,15 @@ def patch_keycloak_api(get_realm_role, create_realm_role=None, update_realm_role
with patch.object(obj, 'create_realm_role', side_effect=create_realm_role) as mock_create_realm_role: with patch.object(obj, 'create_realm_role', side_effect=create_realm_role) as mock_create_realm_role:
with patch.object(obj, 'update_realm_role', side_effect=update_realm_role) as mock_update_realm_role: with patch.object(obj, 'update_realm_role', side_effect=update_realm_role) as mock_update_realm_role:
with patch.object(obj, 'delete_realm_role', side_effect=delete_realm_role) as mock_delete_realm_role: with patch.object(obj, 'delete_realm_role', side_effect=delete_realm_role) as mock_delete_realm_role:
yield mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role with patch.object(obj, 'get_client_role', side_effect=get_client_role) as mock_get_client_role:
with patch.object(obj, 'create_client_role', side_effect=create_client_role) as mock_create_client_role:
with patch.object(obj, 'update_client_role', side_effect=update_client_role) as mock_update_client_role:
with patch.object(obj, 'delete_client_role', side_effect=delete_client_role) as mock_delete_client_role:
with patch.object(obj, 'get_client_by_id', side_effect=get_client_by_id) as mock_get_client_by_id:
with patch.object(obj, 'get_role_composites', side_effect=get_role_composites) as mock_get_role_composites:
yield mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role, \
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role, \
mock_get_client_by_id, mock_get_role_composites
def get_response(object_with_future_response, method, get_id_call_count): def get_response(object_with_future_response, method, get_id_call_count):
@ -125,7 +135,9 @@ class TestKeycloakRealmRole(ModuleTestCase):
with mock_good_connection(): with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_absent, create_realm_role=return_value_created) \ with patch_keycloak_api(get_realm_role=return_value_absent, create_realm_role=return_value_created) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info: with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main() self.module.main()
@ -179,7 +191,9 @@ class TestKeycloakRealmRole(ModuleTestCase):
with mock_good_connection(): with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \ with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info: with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main() self.module.main()
@ -233,7 +247,9 @@ class TestKeycloakRealmRole(ModuleTestCase):
with mock_good_connection(): with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \ with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info: with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main() self.module.main()
@ -244,6 +260,140 @@ class TestKeycloakRealmRole(ModuleTestCase):
# Verify that the module's changed status matches what is expected # Verify that the module's changed status matches what is expected
self.assertIs(exec_info.exception.args[0]['changed'], changed) self.assertIs(exec_info.exception.args[0]['changed'], changed)
def test_create_with_composites_when_present_no_change(self):
"""Update without change a realm role"""
module_args = {
'auth_keycloak_url': 'http://keycloak.url/auth',
'auth_password': 'admin',
'auth_realm': 'master',
'auth_username': 'admin',
'auth_client_id': 'admin-cli',
'validate_certs': True,
'realm': 'realm-name',
'name': 'role-name',
'description': 'role-description',
'composite': True,
'composites': [
{
'client_id': 'client_1',
'name': 'client-role1'
},
{
'name': 'realm-role-1'
}
]
}
return_value_present = [
{
"attributes": {},
"clientRole": False,
"composite": True,
"containerId": "realm-name",
"description": "role-description",
"id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966",
"name": "role-name",
},
{
"attributes": {},
"clientRole": False,
"composite": True,
"containerId": "realm-name",
"description": "role-description",
"id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966",
"name": "role-name",
}
]
return_value_updated = [None]
return_get_role_composites = [
[
{
'clientRole': True,
'containerId': 'c4367fac-f427-11ed-8e2f-aff070d20f0e',
'name': 'client-role1'
},
{
'clientRole': False,
'containerId': 'realm-name',
'name': 'realm-role-1'
}
]
]
return_get_client_by_client_id = [
{
"id": "de152444-f126-4a7a-8273-4ee1544133ad",
"clientId": "client_1",
"name": "client_1",
"description": "client_1",
"surrogateAuthRequired": False,
"enabled": True,
"alwaysDisplayInConsole": False,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"http://localhost:8080/*",
],
"webOrigins": [
"*"
],
"notBefore": 0,
"bearerOnly": False,
"consentRequired": False,
"standardFlowEnabled": True,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"publicClient": False,
"frontchannelLogout": False,
"protocol": "openid-connect",
"attributes": {
"backchannel.logout.session.required": "true",
"backchannel.logout.revoke.offline.tokens": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": True,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
]
changed = False
set_module_args(module_args)
# Run the module
with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_present, update_realm_role=return_value_updated,
get_client_by_id=return_get_client_by_client_id,
get_role_composites=return_get_role_composites) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main()
self.assertEqual(len(mock_get_realm_role.mock_calls), 1)
self.assertEqual(len(mock_create_realm_role.mock_calls), 0)
self.assertEqual(len(mock_update_realm_role.mock_calls), 0)
self.assertEqual(len(mock_get_client_by_client_id.mock_calls), 1)
self.assertEqual(len(mock_get_role_composites.mock_calls), 1)
# Verify that the module's changed status matches what is expected
self.assertIs(exec_info.exception.args[0]['changed'], changed)
def test_delete_when_absent(self): def test_delete_when_absent(self):
"""Remove an absent realm role""" """Remove an absent realm role"""
@ -268,7 +418,9 @@ class TestKeycloakRealmRole(ModuleTestCase):
with mock_good_connection(): with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \ with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info: with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main() self.module.main()
@ -312,7 +464,9 @@ class TestKeycloakRealmRole(ModuleTestCase):
with mock_good_connection(): with mock_good_connection():
with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \ with patch_keycloak_api(get_realm_role=return_value_absent, delete_realm_role=return_value_deleted) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role): as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info: with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main() self.module.main()
@ -323,5 +477,207 @@ class TestKeycloakRealmRole(ModuleTestCase):
self.assertIs(exec_info.exception.args[0]['changed'], changed) self.assertIs(exec_info.exception.args[0]['changed'], changed)
class TestKeycloakClientRole(ModuleTestCase):
def setUp(self):
super(TestKeycloakClientRole, self).setUp()
self.module = keycloak_role
def test_create_client_role_with_composites_when_absent(self):
"""Update with change a realm role"""
module_args = {
'auth_keycloak_url': 'http://keycloak.url/auth',
'auth_password': 'admin',
'auth_realm': 'master',
'auth_username': 'admin',
'auth_client_id': 'admin-cli',
'validate_certs': True,
'realm': 'realm-name',
'client_id': 'client-name',
'name': 'role-name',
'description': 'role-description',
'composite': True,
'composites': [
{
'client_id': 'client_1',
'name': 'client-role1'
},
{
'name': 'realm-role-1'
}
]
}
return_get_client_role = [
None,
{
"attributes": {},
"clientRole": True,
"composite": True,
"composites": [
{
'client': {
'client1': ['client-role1']
}
},
{
'realm': ['realm-role-1']
}
],
"containerId": "9ae25ec2-f40a-11ed-9261-b3bacf720f69",
"description": "role-description",
"id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966",
"name": "role-name",
}
]
changed = True
set_module_args(module_args)
# Run the module
with mock_good_connection():
with patch_keycloak_api(get_client_role=return_get_client_role) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main()
self.assertEqual(len(mock_get_realm_role.mock_calls), 0)
self.assertEqual(len(mock_create_realm_role.mock_calls), 0)
self.assertEqual(len(mock_update_realm_role.mock_calls), 0)
self.assertEqual(len(mock_get_client_role.mock_calls), 2)
self.assertEqual(len(mock_create_client_role.mock_calls), 1)
self.assertEqual(len(mock_update_client_role.mock_calls), 0)
# Verify that the module's changed status matches what is expected
self.assertIs(exec_info.exception.args[0]['changed'], changed)
def test_create_client_role_with_composites_when_present_no_change(self):
"""Update with change a realm role"""
module_args = {
'auth_keycloak_url': 'http://keycloak.url/auth',
'auth_password': 'admin',
'auth_realm': 'master',
'auth_username': 'admin',
'auth_client_id': 'admin-cli',
'validate_certs': True,
'realm': 'realm-name',
'client_id': 'client-name',
'name': 'role-name',
'description': 'role-description',
'composite': True,
'composites': [
{
'client_id': 'client_1',
'name': 'client-role1'
},
{
'name': 'realm-role-1'
}
]
}
return_get_client_role = [
{
"attributes": {},
"clientRole": True,
"composite": True,
"containerId": "9ae25ec2-f40a-11ed-9261-b3bacf720f69",
"description": "role-description",
"id": "90f1cdb6-be88-496e-89c6-da1fb6bc6966",
"name": "role-name",
}
]
return_get_role_composites = [
[
{
'clientRole': True,
'containerId': 'c4367fac-f427-11ed-8e2f-aff070d20f0e',
'name': 'client-role1'
},
{
'clientRole': False,
'containerId': 'realm-name',
'name': 'realm-role-1'
}
]
]
return_get_client_by_client_id = [
{
"id": "de152444-f126-4a7a-8273-4ee1544133ad",
"clientId": "client_1",
"name": "client_1",
"description": "client_1",
"surrogateAuthRequired": False,
"enabled": True,
"alwaysDisplayInConsole": False,
"clientAuthenticatorType": "client-secret",
"redirectUris": [
"http://localhost:8080/*",
],
"webOrigins": [
"*"
],
"notBefore": 0,
"bearerOnly": False,
"consentRequired": False,
"standardFlowEnabled": True,
"implicitFlowEnabled": False,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"publicClient": False,
"frontchannelLogout": False,
"protocol": "openid-connect",
"attributes": {
"backchannel.logout.session.required": "true",
"backchannel.logout.revoke.offline.tokens": "false"
},
"authenticationFlowBindingOverrides": {},
"fullScopeAllowed": True,
"nodeReRegistrationTimeout": -1,
"defaultClientScopes": [
"web-origins",
"acr",
"profile",
"roles",
"email"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt"
]
}
]
changed = False
set_module_args(module_args)
# Run the module
with mock_good_connection():
with patch_keycloak_api(get_client_role=return_get_client_role, get_client_by_id=return_get_client_by_client_id,
get_role_composites=return_get_role_composites) \
as (mock_get_realm_role, mock_create_realm_role, mock_update_realm_role, mock_delete_realm_role,
mock_get_client_role, mock_create_client_role, mock_update_client_role, mock_delete_client_role,
mock_get_client_by_client_id, mock_get_role_composites):
with self.assertRaises(AnsibleExitJson) as exec_info:
self.module.main()
self.assertEqual(len(mock_get_realm_role.mock_calls), 0)
self.assertEqual(len(mock_create_realm_role.mock_calls), 0)
self.assertEqual(len(mock_update_realm_role.mock_calls), 0)
self.assertEqual(len(mock_get_client_role.mock_calls), 1)
self.assertEqual(len(mock_create_client_role.mock_calls), 0)
self.assertEqual(len(mock_update_client_role.mock_calls), 0)
self.assertEqual(len(mock_get_client_by_client_id.mock_calls), 1)
self.assertEqual(len(mock_get_role_composites.mock_calls), 1)
# Verify that the module's changed status matches what is expected
self.assertIs(exec_info.exception.args[0]['changed'], changed)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()