Skip to content

Commit

Permalink
keycloak_group: support keycloak subgroups (#5814)
Browse files Browse the repository at this point in the history
* feat(module/keycloak_group): add support for ...

... handling subgroups

* added changelog fragment and fixing sanity ...

... test issues

* more sanity fixes

* fix missing version and review issues

* added missing licence header

* fix docu

* fix line beeing too long

* replaced suboptimal string type prefixing ...

... with better subdict based approach

* fix sanity issues

* more sanity fixing

* fixed more review issues

* fix argument list too long

* why is it failing? something wrong with the docu?

* is it this line then?

* undid group attribute removing, it does not ...

... belong into this PR

* fix version_added for parents parameter

---------

Co-authored-by: Mirko Wilhelmi <Mirko.Wilhelmi@sma.de>
(cherry picked from commit 7d3e6d1)
  • Loading branch information
morco authored and patchback[bot] committed Feb 25, 2023
1 parent 470f4e8 commit c4c092d
Show file tree
Hide file tree
Showing 7 changed files with 796 additions and 9 deletions.
2 changes: 2 additions & 0 deletions changelogs/fragments/5814-support-keycloak-subgroups.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- keycloak_group - add new optional module parameter ``parents`` to properly handle keycloak subgroups (https://github.com/ansible-collections/community.general/pull/5814).
138 changes: 136 additions & 2 deletions plugins/module_utils/identity/keycloak/keycloak.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates"
URL_GROUPS = "{url}/admin/realms/{realm}/groups"
URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}"
URL_GROUP_CHILDREN = "{url}/admin/realms/{realm}/groups/{groupid}/children"

URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes"
URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}"
Expand Down Expand Up @@ -1249,7 +1250,7 @@ def get_group_by_groupid(self, gid, realm="master"):
self.module.fail_json(msg="Could not fetch group %s in realm %s: %s"
% (gid, realm, str(e)))

def get_group_by_name(self, name, realm="master"):
def get_group_by_name(self, name, realm="master", parents=None):
""" Fetch a keycloak group within a realm based on its name.
The Keycloak API does not allow filtering of the Groups resource by name.
Expand All @@ -1259,10 +1260,19 @@ def get_group_by_name(self, name, realm="master"):
If the group does not exist, None is returned.
:param name: Name of the group to fetch.
:param realm: Realm in which the group resides; default 'master'
:param parents: Optional list of parents when group to look for is a subgroup
"""
groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm)
try:
all_groups = self.get_groups(realm=realm)
if parents:
parent = self.get_subgroup_direct_parent(parents, realm)

if not parent:
return None

all_groups = parent['subGroups']
else:
all_groups = self.get_groups(realm=realm)

for group in all_groups:
if group['name'] == name:
Expand All @@ -1274,6 +1284,102 @@ def get_group_by_name(self, name, realm="master"):
self.module.fail_json(msg="Could not fetch group %s in realm %s: %s"
% (name, realm, str(e)))

def _get_normed_group_parent(self, parent):
""" Converts parent dict information into a more easy to use form.
:param parent: parent describing dict
"""
if parent['id']:
return (parent['id'], True)

return (parent['name'], False)

def get_subgroup_by_chain(self, name_chain, realm="master"):
""" Access a subgroup API object by walking down a given name/id chain.
Groups can be given either as by name or by ID, the first element
must either be a toplvl group or given as ID, all parents must exist.
If the group cannot be found, None is returned.
:param name_chain: Topdown ordered list of subgroup parent (ids or names) + its own name at the end
:param realm: Realm in which the group resides; default 'master'
"""
cp = name_chain[0]

# for 1st parent in chain we must query the server
cp, is_id = self._get_normed_group_parent(cp)

if is_id:
tmp = self.get_group_by_groupid(cp, realm=realm)
else:
# given as name, assume toplvl group
tmp = self.get_group_by_name(cp, realm=realm)

if not tmp:
return None

for p in name_chain[1:]:
for sg in tmp['subGroups']:
pv, is_id = self._get_normed_group_parent(p)

if is_id:
cmpkey = "id"
else:
cmpkey = "name"

if pv == sg[cmpkey]:
tmp = sg
break

if not tmp:
return None

return tmp

def get_subgroup_direct_parent(self, parents, realm="master", children_to_resolve=None):
""" Get keycloak direct parent group API object for a given chain of parents.
To succesfully work the API for subgroups we actually dont need
to "walk the whole tree" for nested groups but only need to know
the ID for the direct predecessor of current subgroup. This
method will guarantee us this information getting there with
as minimal work as possible.
Note that given parent list can and might be incomplete at the
upper levels as long as it starts with an ID instead of a name
If the group does not exist, None is returned.
:param parents: Topdown ordered list of subgroup parents
:param realm: Realm in which the group resides; default 'master'
"""
if children_to_resolve is None:
# start recursion by reversing parents (in optimal cases
# we dont need to walk the whole tree upwarts)
parents = list(reversed(parents))
children_to_resolve = []

if not parents:
# walk complete parents list to the top, all names, no id's,
# try to resolve it assuming list is complete and 1st
# element is a toplvl group
return self.get_subgroup_by_chain(list(reversed(children_to_resolve)), realm=realm)

cp = parents[0]
unused, is_id = self._get_normed_group_parent(cp)

if is_id:
# current parent is given as ID, we can stop walking
# upwards searching for an entry point
return self.get_subgroup_by_chain([cp] + list(reversed(children_to_resolve)), realm=realm)
else:
# current parent is given as name, it must be resolved
# later, try next parent (recurse)
children_to_resolve.append(cp)
return self.get_subgroup_direct_parent(
parents[1:],
realm=realm, children_to_resolve=children_to_resolve
)

def create_group(self, grouprep, realm="master"):
""" Create a Keycloak group.
Expand All @@ -1288,6 +1394,34 @@ def create_group(self, grouprep, realm="master"):
self.module.fail_json(msg="Could not create group %s in realm %s: %s"
% (grouprep['name'], realm, str(e)))

def create_subgroup(self, parents, grouprep, realm="master"):
""" Create a Keycloak subgroup.
:param parents: list of one or more parent groups
:param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name.
:return: HTTPResponse object on success
"""
parent_id = "---UNDETERMINED---"
try:
parent_id = self.get_subgroup_direct_parent(parents, realm)

if not parent_id:
raise Exception(
"Could not determine subgroup parent ID for given"
" parent chain {0}. Assure that all parents exist"
" already and the list is complete and properly"
" ordered, starts with an ID or starts at the"
" top level".format(parents)
)

parent_id = parent_id["id"]
url = URL_GROUP_CHILDREN.format(url=self.baseurl, realm=realm, groupid=parent_id)
return open_url(url, method='POST', http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
data=json.dumps(grouprep), validate_certs=self.validate_certs)
except Exception as e:
self.module.fail_json(msg="Could not create subgroup %s for parent group %s in realm %s: %s"
% (grouprep['name'], parent_id, realm, str(e)))

def update_group(self, grouprep, realm="master"):
""" Update an existing group.
Expand Down
122 changes: 115 additions & 7 deletions plugins/modules/keycloak_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
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).
Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/20.0.2/rest-api/index.html).
- Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will
be returned that way by this module. You may pass single values for attributes when calling the module,
Expand All @@ -42,7 +42,9 @@
description:
- State of the group.
- On C(present), the group will be created if it does not yet exist, or updated with the parameters you provide.
- On C(absent), the group will be removed if it exists.
- >-
On C(absent), the group will be removed if it exists. Be aware that absenting
a group with subgroups will automatically delete all its subgroups too.
default: 'present'
type: str
choices:
Expand Down Expand Up @@ -74,6 +76,38 @@
- A dict of key/value pairs to set as custom attributes for the group.
- Values may be single values (e.g. a string) or a list of strings.
parents:
version_added: "6.4.0"
type: list
description:
- List of parent groups for the group to handle sorted top to bottom.
- >-
Set this to create a group as a subgroup of another group or groups (parents) or
when accessing an existing subgroup by name.
- >-
Not necessary to set when accessing an existing subgroup by its C(ID) because in
that case the group can be directly queried without necessarily knowing its parent(s).
elements: dict
suboptions:
id:
type: str
description:
- Identify parent by ID.
- Needs less API calls than using I(name).
- A deep parent chain can be started at any point when first given parent is given as ID.
- Note that in principle both ID and name can be specified at the same time
but current implementation only always use just one of them, with ID
being preferred.
name:
type: str
description:
- Identify parent by name.
- Needs more internal API calls than using I(id) to map names to ID's under the hood.
- When giving a parent chain with only names it must be complete up to the top.
- Note that in principle both ID and name can be specified at the same time
but current implementation only always use just one of them, with ID
being preferred.
notes:
- Presently, the I(realmRoles), I(clientRoles) and I(access) attributes returned by the Keycloak API
are read-only for groups. This limitation will be removed in a later version of this module.
Expand All @@ -97,6 +131,7 @@
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
register: result_new_kcgrp
delegate_to: localhost
- name: Create a Keycloak group, authentication with token
Expand Down Expand Up @@ -162,6 +197,64 @@
- list
- items
delegate_to: localhost
- name: Create a Keycloak subgroup of a base group (using parent name)
community.general.keycloak_group:
name: my-new-kc-group-sub
realm: MyCustomRealm
state: present
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
parents:
- name: my-new-kc-group
register: result_new_kcgrp_sub
delegate_to: localhost
- name: Create a Keycloak subgroup of a base group (using parent id)
community.general.keycloak_group:
name: my-new-kc-group-sub2
realm: MyCustomRealm
state: present
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
parents:
- id: "{{ result_new_kcgrp.end_state.id }}"
delegate_to: localhost
- name: Create a Keycloak subgroup of a subgroup (using parent names)
community.general.keycloak_group:
name: my-new-kc-group-sub-sub
realm: MyCustomRealm
state: present
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
parents:
- name: my-new-kc-group
- name: my-new-kc-group-sub
delegate_to: localhost
- name: Create a Keycloak subgroup of a subgroup (using direct parent id)
community.general.keycloak_group:
name: my-new-kc-group-sub-sub
realm: MyCustomRealm
state: present
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
parents:
- id: "{{ result_new_kcgrp_sub.end_state.id }}"
delegate_to: localhost
'''

RETURN = '''
Expand Down Expand Up @@ -240,6 +333,13 @@ def main():
id=dict(type='str'),
name=dict(type='str'),
attributes=dict(type='dict'),
parents=dict(
type='list', elements='dict',
options=dict(
id=dict(type='str'),
name=dict(type='str')
),
),
)

argument_spec.update(meta_args)
Expand All @@ -266,6 +366,8 @@ def main():
name = module.params.get('name')
attributes = module.params.get('attributes')

parents = module.params.get('parents')

# attributes in Keycloak have their values returned as lists
# via the API. attributes is a dict, so we'll transparently convert
# the values to lists.
Expand All @@ -275,12 +377,12 @@ def main():

# Filter and map the parameters names that apply to the group
group_params = [x for x in module.params
if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and
if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'parents'] and
module.params.get(x) is not None]

# See if it already exists in Keycloak
if gid is None:
before_group = kc.get_group_by_name(name, realm=realm)
before_group = kc.get_group_by_name(name, realm=realm, parents=parents)
else:
before_group = kc.get_group_by_groupid(gid, realm=realm)

Expand Down Expand Up @@ -323,9 +425,15 @@ def main():
if module.check_mode:
module.exit_json(**result)

# create it
kc.create_group(desired_group, realm=realm)
after_group = kc.get_group_by_name(name, realm)
# create it ...
if parents:
# ... as subgroup of another parent group
kc.create_subgroup(parents, desired_group, realm=realm)
else:
# ... as toplvl base group
kc.create_group(desired_group, realm=realm)

after_group = kc.get_group_by_name(name, realm, parents=parents)

result['end_state'] = after_group

Expand Down
5 changes: 5 additions & 0 deletions tests/integration/targets/keycloak_group/aliases
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

unsupported
Loading

0 comments on commit c4c092d

Please sign in to comment.