diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index fc8d40fb3ef..0ef00a79cab 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -391,6 +391,7 @@ files: maintainers: tbielawa dagwieers sm4rk0 cmprescott $modules/identity/ipa/: maintainers: $team_ipa + $modules/identity/keycloak/: eikef $modules/identity/opendj/opendj_backendprop.py: dj-wasabi $modules/inventory/add_host.py: $team_ansible $modules/inventory/group_by.py: $team_ansible jhoekx @@ -1009,6 +1010,8 @@ files: $module_utils/k8s_common.py: maintainers: chouseknecht sdoran maxamillion fabianvf flaper87 labels: clustering + $module_utils/keycloak.py: + maintainers: eikef $module_utils/manageiq.py: maintainers: $team_manageiq $module_utils/network/netscaler: diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py index 29321bb4fa6..475cb4ccdb9 100644 --- a/lib/ansible/module_utils/keycloak.py +++ b/lib/ansible/module_utils/keycloak.py @@ -41,6 +41,9 @@ URL_CLIENTS = "{url}/admin/realms/{realm}/clients" URL_CLIENT_ROLES = "{url}/admin/realms/{realm}/clients/{id}/roles" 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" + def keycloak_argument_spec(): """ @@ -92,6 +95,9 @@ class KeycloakAPI(object): try: r = json.load(open_url(auth_url, method='POST', validate_certs=self.validate_certs, data=urlencode(payload))) + except ValueError as e: + self.module.fail_json(msg='API returned invalid JSON when trying to obtain access token from %s: %s' + % (auth_url, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain access token from %s: %s' % (auth_url, str(e))) @@ -118,6 +124,9 @@ class KeycloakAPI(object): try: return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders, validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of clients for realm %s: %s' + % (realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' % (realm, str(e))) @@ -135,7 +144,7 @@ class KeycloakAPI(object): return None def get_client_by_id(self, id, realm='master'): - """ Obtain client representatio by id + """ Obtain client representation by id :param id: id (not clientId) of client to be queried :param realm: client from this realm @@ -153,10 +162,26 @@ class KeycloakAPI(object): else: self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' % (id, realm, str(e))) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client %s for realm %s: %s' + % (id, realm, str(e))) except Exception as e: self.module.fail_json(msg='Could not obtain client %s for realm %s: %s' % (id, realm, str(e))) + def get_client_id(self, client_id, realm='master'): + """ Obtain id of client by client_id + + :param client_id: client_id of client to be queried + :param realm: client template from this realm + :return: id of client (usually a UUID) + """ + result = self.get_client_by_clientid(client_id, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + def update_client(self, id, clientrep, realm="master"): """ Update an existing client :param id: id (not clientId) of client to be updated in Keycloak @@ -203,3 +228,114 @@ class KeycloakAPI(object): except Exception as e: self.module.fail_json(msg='Could not delete client %s in realm %s: %s' % (id, realm, str(e))) + + def get_client_templates(self, realm='master'): + """ Obtains client template representations for client templates in a realm + + :param realm: realm to be queried + :return: list of dicts of client representations + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of client templates for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of client templates for realm %s: %s' + % (realm, str(e))) + + def get_client_template_by_id(self, id, realm='master'): + """ Obtain client template representation by id + + :param id: id (not name) of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, id=id, realm=realm) + + try: + return json.load(open_url(url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain client templates %s for realm %s: %s' + % (id, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain client template %s for realm %s: %s' + % (id, realm, str(e))) + + def get_client_template_by_name(self, name, realm='master'): + """ Obtain client template representation by name + + :param name: name of client template to be queried + :param realm: client template from this realm + :return: dict of client template representation or None if none matching exist + """ + result = self.get_client_templates(realm) + if isinstance(result, list): + result = [x for x in result if x['name'] == name] + if len(result) > 0: + return result[0] + return None + + def get_client_template_id(self, name, realm='master'): + """ Obtain client template id by name + + :param name: name of client template to be queried + :param realm: client template from this realm + :return: client template id (usually a UUID) + """ + result = self.get_client_template_by_name(name, realm) + if isinstance(result, dict) and 'id' in result: + return result['id'] + else: + return None + + def update_client_template(self, id, clienttrep, realm="master"): + """ Update an existing client template + :param id: id (not name) of client template to be updated in Keycloak + :param clienttrep: corresponding (partial/full) client template representation with updates + :param realm: realm the client template is in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(url, method='PUT', headers=self.restheaders, + data=json.dumps(clienttrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update client template %s in realm %s: %s' + % (id, realm, str(e))) + + def create_client_template(self, clienttrep, realm="master"): + """ Create a client in keycloak + :param clienttrep: Client template representation of client template to be created. Must at least contain field name + :param realm: realm for client template to be created in + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATES.format(url=self.baseurl, realm=realm) + + try: + return open_url(url, method='POST', headers=self.restheaders, + data=json.dumps(clienttrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create client template %s in realm %s: %s' + % (clienttrep['clientId'], realm, str(e))) + + def delete_client_template(self, id, realm="master"): + """ Delete a client template from Keycloak + + :param id: id (not name) of client to be deleted + :param realm: realm of client template to be deleted + :return: HTTPResponse object on success + """ + url = URL_CLIENTTEMPLATE.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete client template %s in realm %s: %s' + % (id, realm, str(e))) diff --git a/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py new file mode 100644 index 00000000000..cb7c4e8fcbd --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_clienttemplate.py @@ -0,0 +1,405 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017, Eike Frost +# 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_clienttemplate + +short_description: Allows administration of Keycloak client templates via Keycloak API + +version_added: "2.5" + +description: + - This module allows the administration of Keycloak client templates 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/) + + - The Keycloak API does not always enforce for only sensible settings to be used -- you can set + SAML-specific settings on an OpenID Connect client for instance and vice versa. Be careful. + If you do not specify a setting, usually a sensible default is chosen. + +options: + state: + description: + - State of the client template + - On C(present), the client template will be created (or updated if it exists already). + - On C(absent), the client template will be removed if it exists + choices: ['present', 'absent'] + default: 'present' + + id: + description: + - Id of client template to be worked on. This is usually a UUID. + + realm: + description: + - Realm this client template is found in. + + name: + description: + - Name of the client template + + description: + description: + - Description of the client template in Keycloak + + protocol: + description: + - Type of client template (either C(openid-connect) or C(saml). + choices: ['openid-connect', 'saml'] + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client template or not. + This is 'fullScopeAllowed' in the Keycloak REST API. + + protocol_mappers: + description: + - a list of dicts defining protocol mappers for this client template. + This is 'protocolMappers' in the Keycloak REST API. + suboptions: + consentRequired: + description: + - Specifies whether a user needs to provide consent to a client for this mapper to be active. + + consentText: + description: + - The human-readable name of the consent the user is presented to accept. + + id: + description: + - Usually a UUID specifying the internal ID of this protocol mapper instance. + + name: + description: + - The name of this protocol mapper. + + protocol: + description: + - is either 'openid-connect' or 'saml', this specifies for which protocol this protocol mapper + is active. + choices: ['openid-connect', 'saml'] + + protocolMapper: + description: + - The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is + impossible to provide since this may be extended through SPIs by the user of Keycloak, + by default Keycloak as of 3.4 ships with at least + - C(docker-v2-allow-all-mapper) + - C(oidc-address-mapper) + - C(oidc-full-name-mapper) + - C(oidc-group-membership-mapper) + - C(oidc-hardcoded-claim-mapper) + - C(oidc-hardcoded-role-mapper) + - C(oidc-role-name-mapper) + - C(oidc-script-based-protocol-mapper) + - C(oidc-sha256-pairwise-sub-mapper) + - C(oidc-usermodel-attribute-mapper) + - C(oidc-usermodel-client-role-mapper) + - C(oidc-usermodel-property-mapper) + - C(oidc-usermodel-realm-role-mapper) + - C(oidc-usersessionmodel-note-mapper) + - C(saml-group-membership-mapper) + - C(saml-hardcode-attribute-mapper) + - C(saml-hardcode-role-mapper) + - C(saml-role-list-mapper) + - C(saml-role-name-mapper) + - C(saml-user-attribute-mapper) + - C(saml-user-property-mapper) + - C(saml-user-session-note-mapper) + - An exhaustive list of available mappers on your installation can be obtained on + the admin console by going to Server Info -> Providers and looking under + 'protocol-mapper'. + + config: + description: + - Dict specifying the configuration options for the protocol mapper; the + contents differ depending on the value of I(protocolMapper) and are not documented + other than by the source of the mappers and its parent class(es). An example is given + below. It is easiest to obtain valid config values by dumping an already-existing + protocol mapper configuration through check-mode in the "existing" field. + + attributes: + description: + - A dict of further attributes for this client template. This can contain various + configuration settings, though in the default installation of Keycloak as of 3.4, none + are documented or known, so this is usually empty. + +notes: +- The Keycloak REST API defines further fields (namely I(bearerOnly), I(consentRequired), I(standardFlowEnabled), + I(implicitFlowEnabled), I(directAccessGrantsEnabled), I(serviceAccountsEnabled), I(publicClient), and + I(frontchannelLogout)) which, while available with keycloak_client, do not have any effect on + Keycloak client-templates and are discarded if supplied with an API request changing client-templates. As such, + they are not available through this module. + +extends_documentation_fragment: + - keycloak + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Create or update Keycloak client template (minimal) + local_action: + module: keycloak_clienttemplate + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: this_is_a_test + +- name: delete Keycloak client template + local_action: + module: keycloak_clienttemplate + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + state: absent + name: test01 + +- name: Create or update Keycloak client template (with a protocol mapper) + local_action: + module: keycloak_clienttemplate + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + realm: master + name: this_is_a_test + protocol_mappers: + - config: + access.token.claim: True + claim.name: "family_name" + id.token.claim: True + jsonType.label: String + user.attribute: lastName + userinfo.token.claim: True + consentRequired: True + consentText: "${familyName}" + name: family name + protocol: openid-connect + protocolMapper: oidc-usermodel-property-mapper + full_scope_allowed: false + id: bce6f5e9-d7d3-4955-817e-c5b7f8d65b3f +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: string + sample: "Client template testclient has been updated" + +proposed: + description: client template representation of proposed changes to client template + returned: always + type: dict + sample: { + name: "test01" + } +existing: + description: client template representation of existing client template (sample is truncated) + returned: always + type: dict + sample: { + "description": "test01", + "fullScopeAllowed": false, + "id": "9c3712ab-decd-481e-954f-76da7b006e5f", + "name": "test01", + "protocol": "saml" + } +end_state: + description: client template representation of client template after module execution (sample is truncated) + returned: always + type: dict + sample: { + "description": "test01", + "fullScopeAllowed": false, + "id": "9c3712ab-decd-481e-954f-76da7b006e5f", + "name": "test01", + "protocol": "saml" + } +''' + +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() + + protmapper_spec = dict( + consentRequired=dict(type='bool'), + consentText=dict(type='str'), + id=dict(type='str'), + name=dict(type='str'), + protocol=dict(type='str', choices=['openid-connect', 'saml']), + protocolMapper=dict(type='str'), + config=dict(type='dict'), + ) + + meta_args = dict( + realm=dict(type='str', default='master'), + state=dict(default='present', choices=['present', 'absent']), + + id=dict(type='str'), + name=dict(type='str'), + description=dict(type='str'), + protocol=dict(type='str', choices=['openid-connect', 'saml']), + attributes=dict(type='dict'), + full_scope_allowed=dict(type='bool'), + protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec), + ) + 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={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + kc = KeycloakAPI(module) + + realm = module.params.get('realm') + state = module.params.get('state') + cid = module.params.get('id') + + # convert module parameters to client representation parameters (if they belong in there) + clientt_params = [x for x in module.params + if x not in ['state', 'auth_keycloak_url', 'auth_client_id', 'auth_realm', + 'auth_client_secret', 'auth_username', 'auth_password', + 'validate_certs', 'realm'] and module.params.get(x) is not None] + + # See whether the client template already exists in Keycloak + if cid is None: + before_clientt = kc.get_client_template_by_name(module.params.get('name'), realm=realm) + if before_clientt is not None: + cid = before_clientt['id'] + else: + before_clientt = kc.get_client_template_by_id(cid, realm=realm) + + if before_clientt is None: + before_clientt = dict() + + result['existing'] = before_clientt + + # Build a proposed changeset from parameters given to this module + changeset = dict() + + for clientt_param in clientt_params: + # lists in the Keycloak API are sorted + new_param_value = module.params.get(clientt_param) + if isinstance(new_param_value, list): + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + changeset[camel(clientt_param)] = new_param_value + + # Whether creating or updating a client, take the before-state and merge the changeset into it + updated_clientt = before_clientt.copy() + updated_clientt.update(changeset) + + result['proposed'] = changeset + + # If the client template does not exist yet, before_client is still empty + if before_clientt == dict(): + if state == 'absent': + # do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'Client template does not exist, doing nothing.' + module.exit_json(**result) + + # create new client template + result['changed'] = True + if 'name' not in updated_clientt: + module.fail_json(msg='name needs to be specified when creating a new client') + + if module._diff: + result['diff'] = dict(before='', after=updated_clientt) + + if module.check_mode: + module.exit_json(**result) + + kc.create_client_template(updated_clientt, realm=realm) + after_clientt = kc.get_client_template_by_name(updated_clientt['name'], realm=realm) + + result['end_state'] = after_clientt + + result['msg'] = 'Client template %s has been created.' % updated_clientt['name'] + module.exit_json(**result) + else: + if state == 'present': + # update existing client template + result['changed'] = True + if module.check_mode: + # We can only compare the current client template with the proposed updates we have + if module._diff: + result['diff'] = dict(before=before_clientt, + after=updated_clientt) + + module.exit_json(**result) + + kc.update_client_template(cid, updated_clientt, realm=realm) + + after_clientt = kc.get_client_template_by_id(cid, realm=realm) + if before_clientt == after_clientt: + result['changed'] = False + if module._diff: + result['diff'] = dict(before=before_clientt, + after=after_clientt) + result['end_state'] = after_clientt + + result['msg'] = 'Client template %s has been updated.' % updated_clientt['name'] + module.exit_json(**result) + else: + # Delete existing client + result['changed'] = True + if module._diff: + result['diff']['before'] = before_clientt + result['diff']['after'] = '' + + if module.check_mode: + module.exit_json(**result) + + kc.delete_client_template(cid, realm=realm) + result['proposed'] = dict() + result['end_state'] = dict() + result['msg'] = 'Client template %s has been deleted.' % before_clientt['name'] + module.exit_json(**result) + + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/lib/ansible/utils/module_docs_fragments/keycloak.py b/lib/ansible/utils/module_docs_fragments/keycloak.py index 4baf6a0a39a..964ca98d295 100644 --- a/lib/ansible/utils/module_docs_fragments/keycloak.py +++ b/lib/ansible/utils/module_docs_fragments/keycloak.py @@ -25,6 +25,8 @@ options: description: - URL to the Keycloak instance. required: true + aliases: + - url auth_client_id: description: @@ -39,21 +41,23 @@ options: auth_client_secret: description: - Client Secret to use in conjunction with I(auth_client_id) (if required). - required: false auth_username: description: - Username to authenticate for API access with. required: true + aliases: + - username auth_password: description: - Password to authenticate for API access with. required: true + aliases: + - password validate_certs: description: - Verify TLS certificates (do not disable this in production). - required: false default: True '''