From dd3de27cc87d63c1d15637d81a6f5c93d1b8904b Mon Sep 17 00:00:00 2001 From: adamgoossens Date: Thu, 11 Apr 2019 05:58:20 +1000 Subject: [PATCH] New module: keycloak_group (#35637) * Initial implementation of groups module. Not all parameters are supported, yet. * Clarify read-only status of realmRoles, clientRoles and access attributes * Fix testing failures * Fix additional style issues. * Minor updates and fixes after review feedback * Simplify return values after feedback This removes the 'proposed' and 'end_state' return values, replacing them instead with just 'group'. 'group' is the representation of the group after the module completes. Also update the dates, and the Ansible version * Corrections after module validation * Further documentation updates after feedback Minor whitespace adjustments also * Add delegate_to: localhost stanzas to examples --- lib/ansible/module_utils/keycloak.py | 133 +++++++ .../identity/keycloak/keycloak_group.py | 358 ++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_group.py diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index 475cb4ccdb..d4855edc8c 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -43,6 +43,8 @@ URL_REALM_ROLES = "{url}/admin/realms/{realm}/roles" URL_CLIENTTEMPLATE = "{url}/admin/realms/{realm}/client-templates/{id}" URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates" +URL_GROUPS = "{url}/admin/realms/{realm}/groups" +URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}" def keycloak_argument_spec(): @@ -339,3 +341,134 @@ class KeycloakAPI(object): except Exception as e: self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' % (id, realm, str(e))) + + def get_groups(self, realm="master"): + """ Fetch the name and ID of all groups on the Keycloak server. + + To fetch the full data of the group, make a subsequent call to + get_group_by_groupid, passing in the ID of the group you wish to return. + + :param realm: Return the groups of this realm (default "master"). + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json(msg="Could not fetch list of groups in realm %s: %s" + % (realm, str(e))) + + def get_group_by_groupid(self, gid, realm="master"): + """ Fetch a keycloak group from the provided realm using the group's unique ID. + + If the group does not exist, None is returned. + + gid is a UUID provided by the Keycloak API + :param gid: UUID of the group to be returned + :param realm: Realm in which the group resides; default 'master'. + """ + groups_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=gid) + try: + return json.load(open_url(groups_url, method="GET", headers=self.restheaders, + validate_certs=self.validate_certs)) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (gid, realm, str(e))) + except Exception as e: + 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"): + """ Fetch a keycloak group within a realm based on its name. + + The Keycloak API does not allow filtering of the Groups resource by name. + As a result, this method first retrieves the entire list of groups - name and ID - + then performs a second query to fetch the group. + + 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' + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + all_groups = self.get_groups(realm=realm) + + for group in all_groups: + if group['name'] == name: + return self.get_group_by_groupid(group['id'], realm=realm) + + return None + + except Exception as e: + self.module.fail_json(msg="Could not fetch group %s in realm %s: %s" + % (name, realm, str(e))) + + def create_group(self, grouprep, realm="master"): + """ Create a Keycloak group. + + :param grouprep: a GroupRepresentation of the group to be created. Must contain at minimum the field name. + :return: HTTPResponse object on success + """ + groups_url = URL_GROUPS.format(url=self.baseurl, realm=realm) + try: + return open_url(groups_url, method='POST', headers=self.restheaders, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg="Could not create group %s in realm %s: %s" + % (grouprep['name'], realm, str(e))) + + def update_group(self, grouprep, realm="master"): + """ Update an existing group. + + :param grouprep: A GroupRepresentation of the updated group. + :return HTTPResponse object on success + """ + group_url = URL_GROUP.format(url=self.baseurl, realm=realm, groupid=grouprep['id']) + + try: + return open_url(group_url, method='PUT', headers=self.restheaders, + data=json.dumps(grouprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update group %s in realm %s: %s' + % (grouprep['name'], realm, str(e))) + + def delete_group(self, name=None, groupid=None, realm="master"): + """ Delete a group. One of name or groupid must be provided. + + Providing the group ID is preferred as it avoids a second lookup to + convert a group name to an ID. + + :param name: The name of the group. A lookup will be performed to retrieve the group ID. + :param groupid: The ID of the group (preferred to name). + :param realm: The realm in which this group resides, default "master". + """ + + if groupid is None and name is None: + # prefer an exception since this is almost certainly a programming error in the module itself. + raise Exception("Unable to delete group - one of group ID or name must be provided.") + + # only lookup the name if groupid isn't provided. + # in the case that both are provided, prefer the ID, since it's one + # less lookup. + if groupid is None and name is not None: + for group in self.get_groups(realm=realm): + if group['name'] == name: + groupid = group['id'] + break + + # if the group doesn't exist - no problem, nothing to delete. + if groupid is None: + return None + + # should have a good groupid by here. + group_url = URL_GROUP.format(realm=realm, groupid=groupid, url=self.baseurl) + try: + return open_url(group_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + + except Exception as e: + self.module.fail_json(msg="Unable to delete group %s: %s" % (groupid, str(e))) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_group.py b/lib/ansible/modules/identity/keycloak/keycloak_group.py new file mode 100644 index 0000000000..0d6ba686b5 --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_group.py @@ -0,0 +1,358 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2019, Adam Goossens +# 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: keycloak_group + +short_description: Allows administration of Keycloak groups via Keycloak API + +description: + - This module allows you to add, remove or modify Keycloak groups via 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(http://www.keycloak.org/docs-api/3.3/rest-api/). + + - 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, + and this will be translated into a list suitable for the API. + + - When updating a group, where possible provide the group ID to the module. This removes a lookup + to the API to translate the name into the group ID. + +version_added: "2.8" + +options: + state: + 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. + required: true + default: 'present' + type: str + choices: + - present + - absent + + name: + type: str + description: + - Name of the group. + - This parameter is required only when creating or updating the group. + + realm: + type: str + description: + - They Keycloak realm under which this group resides. + default: 'master' + + id: + type: str + description: + - The unique identifier for this group. + - This parameter is not required for updating or deleting a group but + providing it will reduce the number of API calls required. + + attributes: + type: dict + description: + - 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. + +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. + +extends_documentation_fragment: + - keycloak + +author: + - Adam Goossens (@adamgoossens) +''' + +EXAMPLES = ''' +- name: Create a Keycloak group + keycloak_group: + name: my-new-kc-group + 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 + delegate_to: localhost + +- name: Delete a keycloak group + keycloak_group: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + state: absent + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Delete a Keycloak group based on name + keycloak_group: + name: my-group-for-deletion + state: absent + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Update the name of a Keycloak group + keycloak_group: + id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd' + name: an-updated-kc-group-name + state: present + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: Create a keycloak group with some custom attributes + keycloak_group: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + name: my-new_group + attributes: + attrib1: value1 + attrib2: value2 + attrib3: + - with + - numerous + - individual + - list + - items + delegate_to: localhost +''' + +RETURN = ''' +group: + description: Group representation of the group after module execution (sample is truncated). + returned: always + type: complex + contains: + id: + description: GUID that identifies the group + type: str + returned: always + sample: 23f38145-3195-462c-97e7-97041ccea73e + name: + description: Name of the group + type: str + returned: always + sample: grp-test-123 + attributes: + description: Attributes applied to this group + type: dict + returned: always + sample: + attr1: ["val1", "val2", "val3"] + path: + description: URI path to the group + type: str + returned: always + sample: /grp-test-123 + realmRoles: + description: An array of the realm-level roles granted to this group + type: list + returned: always + sample: [] + subGroups: + description: A list of groups that are children of this group. These groups will have the same parameters as + documented here. + type: list + returned: always + clientRoles: + description: A list of client-level roles granted to this group + type: list + returned: always + sample: [] + access: + description: A dict describing the accesses you have to this group based on the credentials used. + type: dict + returned: always + sample: + manage: true + manageMembership: true + view: true +''' + +from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +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'), + id=dict(type='str'), + name=dict(type='str'), + attributes=dict(type='dict') + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['id', 'name']])) + + result = dict(changed=False, msg='', diff={}, group='') + + # Obtain access token, initialize API + kc = KeycloakAPI(module) + + realm = module.params.get('realm') + state = module.params.get('state') + gid = module.params.get('id') + name = module.params.get('name') + attributes = module.params.get('attributes') + + before_group = None # current state of the group, for merging. + + # does the group already exist? + if gid is None: + before_group = kc.get_group_by_name(name, realm=realm) + else: + before_group = kc.get_group_by_groupid(gid, realm=realm) + + before_group = {} if before_group is None else before_group + + # 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. + if attributes is not None: + for key, val in module.params['attributes'].items(): + module.params['attributes'][key] = [val] if not isinstance(val, list) else val + + group_params = [x for x in module.params + if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and + module.params.get(x) is not None] + + # build a changeset + changeset = {} + for param in group_params: + new_param_value = module.params.get(param) + old_value = before_group[param] if param in before_group else None + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # prepare the new group + updated_group = before_group.copy() + updated_group.update(changeset) + + # if before_group is none, the group doesn't exist. + if before_group == {}: + if state == 'absent': + # nothing to do. + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'Group does not exist; doing nothing.' + result['group'] = dict() + module.exit_json(**result) + + # for 'present', create a new group. + result['changed'] = True + if name is None: + module.fail_json(msg='name must be specified when creating a new group') + + if module._diff: + result['diff'] = dict(before='', after=updated_group) + + if module.check_mode: + module.exit_json(**result) + + # do it for real! + kc.create_group(updated_group, realm=realm) + after_group = kc.get_group_by_name(name, realm) + + result['group'] = after_group + result['msg'] = 'Group {name} has been created with ID {id}'.format(name=after_group['name'], + id=after_group['id']) + + else: + if state == 'present': + # no changes + if updated_group == before_group: + result['changed'] = False + result['group'] = updated_group + result['msg'] = "No changes required to group {name}.".format(name=before_group['name']) + module.exit_json(**result) + + # update the existing group + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=before_group, after=updated_group) + + if module.check_mode: + module.exit_json(**result) + + # do the update + kc.update_group(updated_group, realm=realm) + + after_group = kc.get_group_by_groupid(updated_group['id'], realm=realm) + + result['group'] = after_group + result['msg'] = "Group {id} has been updated".format(id=after_group['id']) + + module.exit_json(**result) + + elif state == 'absent': + result['group'] = dict() + + if module._diff: + result['diff'] = dict(before=before_group, after='') + + if module.check_mode: + module.exit_json(**result) + + # delete for real + gid = before_group['id'] + kc.delete_group(groupid=gid, realm=realm) + + result['changed'] = True + result['msg'] = "Group {name} has been deleted".format(name=before_group['name']) + + module.exit_json(**result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main()