Skip to content

Commit

Permalink
Inspq keycloak role composites (#6469)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
elfelip and felixfontein authored Jun 15, 2023
1 parent 9f47cdd commit 9395df1
Show file tree
Hide file tree
Showing 7 changed files with 860 additions and 17 deletions.
Original file line number Diff line number Diff line change
@@ -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).
148 changes: 144 additions & 4 deletions plugins/module_utils/identity/keycloak/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,9 @@ def create_realm_role(self, rolerep, realm='master'):
"""
roles_url = URL_REALM_ROLES.format(url=self.baseurl, realm=realm)
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,
data=json.dumps(rolerep), validate_certs=self.validate_certs)
except Exception as e:
Expand All @@ -1694,12 +1697,124 @@ def update_realm_role(self, rolerep, realm='master'):
"""
role_url = URL_REALM_ROLE.format(url=self.baseurl, realm=realm, name=quote(rolerep['name']))
try:
return 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)
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)
if composites is not None:
self.update_role_composites(rolerep=rolerep, composites=composites, realm=realm)
return role_response
except Exception as e:
self.module.fail_json(msg='Could not update role %s in realm %s: %s'
% (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'):
""" Delete a realm role.
Expand Down Expand Up @@ -1778,12 +1893,30 @@ def create_client_role(self, rolerep, clientid, realm='master'):
% (clientid, realm))
roles_url = URL_CLIENT_ROLES.format(url=self.baseurl, realm=realm, id=cid)
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,
data=json.dumps(rolerep), validate_certs=self.validate_certs)
except Exception as e:
self.module.fail_json(msg='Could not create role %s for client %s in realm %s: %s'
% (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"):
""" Update an existing client role.
Expand All @@ -1798,8 +1931,15 @@ def update_client_role(self, rolerep, clientid, realm="master"):
% (clientid, realm))
role_url = URL_CLIENT_ROLE.format(url=self.baseurl, realm=realm, id=cid, name=quote(rolerep['name']))
try:
return 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)
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)
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:
self.module.fail_json(msg='Could not update role %s for client %s in realm %s: %s'
% (rolerep['name'], clientid, realm, str(e)))
Expand Down
77 changes: 71 additions & 6 deletions plugins/modules/keycloak_role.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,42 @@
description:
- 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.
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:
- community.general.keycloak
Expand Down Expand Up @@ -198,8 +234,9 @@
'''

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
import copy


def main():
Expand All @@ -210,13 +247,21 @@ def main():
"""
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(
state=dict(type='str', default='present', choices=['present', 'absent']),
name=dict(type='str', required=True),
description=dict(type='str'),
realm=dict(type='str', default='master'),
client_id=dict(type='str'),
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)
Expand Down Expand Up @@ -250,7 +295,7 @@ def main():

# Filter and map the parameters names that apply to the role
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]

# See if it already exists in Keycloak
Expand All @@ -269,10 +314,10 @@ def main():
new_param_value = module.params.get(param)
old_value = before_role[param] if param in before_role else None
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)
desired_role = before_role.copy()
desired_role = copy.deepcopy(before_role)
desired_role.update(changeset)

result['proposed'] = changeset
Expand Down Expand Up @@ -309,17 +354,35 @@ def main():
kc.create_client_role(desired_role, 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['msg'] = 'Role {name} has been created'.format(name=name)
module.exit_json(**result)

else:
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

# no changes
if desired_role == before_role:
if is_struct_included(desired_role, before_role, exclude=compare_exclude):
result['changed'] = False
result['end_state'] = desired_role
result['msg'] = "No changes required to role {name}.".format(name=name)
Expand All @@ -341,6 +404,8 @@ def main():
else:
kc.update_client_role(desired_role, 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

Expand Down
20 changes: 20 additions & 0 deletions tests/integration/targets/keycloak_role/README.md
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 9395df1

Please sign in to comment.