From 16081d275132431296b2fa8f9fcea0db4b87243f Mon Sep 17 00:00:00 2001 From: Eike Frost Date: Wed, 29 Nov 2017 22:44:35 +0100 Subject: [PATCH] Add keycloak_client module for administration of Keycloak clients (#31716) Allows administration of Keycloak (http://www.keycloak.org/) clients via the Keycloak REST API --- .../dev_guide/developing_module_utilities.rst | 1 + lib/ansible/module_utils/keycloak.py | 205 ++++++ .../modules/identity/keycloak/__init__.py | 0 .../identity/keycloak/keycloak_client.py | 617 ++++++++++++++++++ .../utils/module_docs_fragments/keycloak.py | 59 ++ 5 files changed, 882 insertions(+) create mode 100644 lib/ansible/module_utils/keycloak.py create mode 100644 lib/ansible/modules/identity/keycloak/__init__.py create mode 100644 lib/ansible/modules/identity/keycloak/keycloak_client.py create mode 100644 lib/ansible/utils/module_docs_fragments/keycloak.py diff --git a/docs/docsite/rst/dev_guide/developing_module_utilities.rst b/docs/docsite/rst/dev_guide/developing_module_utilities.rst index 760b809a64..525d827e6d 100644 --- a/docs/docsite/rst/dev_guide/developing_module_utilities.rst +++ b/docs/docsite/rst/dev_guide/developing_module_utilities.rst @@ -28,6 +28,7 @@ The following is a list of module_utils files and a general description. The mod - iosxr.py - Definitions and helper functions for modules that manage Cisco IOS-XR networking devices - ismount.py - Contains single helper function that fixes os.path.ismount - junos.py - Definitions and helper functions for modules that manage Junos networking devices +- keycloak.py - Definitions and helper functions for modules working with the Keycloak API - known_hosts.py - utilities for working with known_hosts file - manageiq.py - Functions and utilities for modules that work with ManageIQ platform and its resources. - mlnxos.py - Definitions and helper functions for modules that manage Mellanox MLNX-OS networking devices diff --git a/lib/ansible/module_utils/keycloak.py b/lib/ansible/module_utils/keycloak.py new file mode 100644 index 0000000000..29321bb4fa --- /dev/null +++ b/lib/ansible/module_utils/keycloak.py @@ -0,0 +1,205 @@ +# Copyright (c) 2017, Eike Frost +# +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json + +from ansible.module_utils.urls import open_url +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils.six.moves.urllib.error import HTTPError + +URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" +URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" +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" + + +def keycloak_argument_spec(): + """ + Returns argument_spec of options common to keycloak_*-modules + + :return: argument_spec dict + """ + return dict( + auth_keycloak_url=dict(type='str', aliases=['url'], required=True), + auth_client_id=dict(type='str', default='admin-cli'), + auth_realm=dict(type='str', required=True), + auth_client_secret=dict(type='str', default=None), + auth_username=dict(type='str', aliases=['username'], required=True), + auth_password=dict(type='str', aliases=['password'], required=True, no_log=True), + validate_certs=dict(type='bool', default=True) + ) + + +def camel(words): + return words.split('_')[0] + ''.join(x.capitalize() or '_' for x in words.split('_')[1:]) + + +class KeycloakAPI(object): + """ Keycloak API access; Keycloak uses OAuth 2.0 to protect its API, an access token for which + is obtained through OpenID connect + """ + def __init__(self, module): + self.module = module + self.token = None + self._connect() + + def _connect(self): + """ Obtains an access_token and saves it for use in API accesses + """ + self.baseurl = self.module.params.get('auth_keycloak_url') + self.validate_certs = self.module.params.get('validate_certs') + + auth_url = URL_TOKEN.format(url=self.baseurl, realm=self.module.params.get('auth_realm')) + + payload = {'grant_type': 'password', + 'client_id': self.module.params.get('auth_client_id'), + 'client_secret': self.module.params.get('auth_client_secret'), + 'username': self.module.params.get('auth_username'), + 'password': self.module.params.get('auth_password')} + + # Remove empty items, for instance missing client_secret + payload = dict((k, v) for k, v in payload.items() if v is not None) + + try: + r = json.load(open_url(auth_url, method='POST', + validate_certs=self.validate_certs, data=urlencode(payload))) + except Exception as e: + self.module.fail_json(msg='Could not obtain access token from %s: %s' + % (auth_url, str(e))) + + if 'access_token' in r: + self.token = r['access_token'] + self.restheaders = {'Authorization': 'Bearer ' + self.token, + 'Content-Type': 'application/json'} + + else: + self.module.fail_json(msg='Could not obtain access token from %s' % auth_url) + + def get_clients(self, realm='master', filter=None): + """ Obtains client representations for clients in a realm + + :param realm: realm to be queried + :param filter: if defined, only the client with clientId specified in the filter is returned + :return: list of dicts of client representations + """ + clientlist_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + if filter is not None: + clientlist_url += '?clientId=%s' % filter + + try: + return json.load(open_url(clientlist_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs)) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of clients for realm %s: %s' + % (realm, str(e))) + + def get_client_by_clientid(self, client_id, realm='master'): + """ Get client representation by clientId + :param client_id: The clientId to be queried + :param realm: realm from which to obtain the client representation + :return: dict with a client representation or None if none matching exist + """ + r = self.get_clients(realm=realm, filter=client_id) + if len(r) > 0: + return r[0] + else: + return None + + def get_client_by_id(self, id, realm='master'): + """ Obtain client representatio by id + + :param id: id (not clientId) of client to be queried + :param realm: client from this realm + :return: dict of client representation or None if none matching exist + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return json.load(open_url(client_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 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 update_client(self, id, clientrep, realm="master"): + """ Update an existing client + :param id: id (not clientId) of client to be updated in Keycloak + :param clientrep: corresponding (partial/full) client representation with updates + :param realm: realm the client is in + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(client_url, method='PUT', headers=self.restheaders, + data=json.dumps(clientrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update client %s in realm %s: %s' + % (id, realm, str(e))) + + def create_client(self, clientrep, realm="master"): + """ Create a client in keycloak + :param clientrep: Client representation of client to be created. Must at least contain field clientId + :param realm: realm for client to be created + :return: HTTPResponse object on success + """ + client_url = URL_CLIENTS.format(url=self.baseurl, realm=realm) + + try: + return open_url(client_url, method='POST', headers=self.restheaders, + data=json.dumps(clientrep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create client %s in realm %s: %s' + % (clientrep['clientId'], realm, str(e))) + + def delete_client(self, id, realm="master"): + """ Delete a client from Keycloak + + :param id: id (not clientId) of client to be deleted + :param realm: realm of client to be deleted + :return: HTTPResponse object on success + """ + client_url = URL_CLIENT.format(url=self.baseurl, realm=realm, id=id) + + try: + return open_url(client_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not delete client %s in realm %s: %s' + % (id, realm, str(e))) diff --git a/lib/ansible/modules/identity/keycloak/__init__.py b/lib/ansible/modules/identity/keycloak/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/identity/keycloak/keycloak_client.py b/lib/ansible/modules/identity/keycloak/keycloak_client.py new file mode 100644 index 0000000000..659a04c0ad --- /dev/null +++ b/lib/ansible/modules/identity/keycloak/keycloak_client.py @@ -0,0 +1,617 @@ +#!/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_client + +short_description: Allows administration of Keycloak clients via Keycloak API + +version_added: "2.5" + +description: + - This module allows the administration of Keycloak clients 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 + - On C(present), the client will be created (or updated if it exists already). + - On C(absent), the client will be removed if it exists + required: false + choices: ['present', 'absent'] + default: 'present' + + client_id: + description: + - Client id of client to be worked on. This is usually an alphanumeric name chosen by + you. Either this or I(id) is required. If you specify both, I(id) takes precedence. + This is 'clientId' in the Keycloak REST API. + required: false + + id: + description: + - Id of client to be worked on. This is usually an UUID. Either this or I(client_id) + is required. If you specify both, this takes precedence. + required: false + + name: + description: + - Name of the client (this is not the same as I(client_id)) + required: false + + description: + description: + - Description of the client in Keycloak + required: false + + root_url: + description: + - Root URL appended to relative URLs for this client + This is 'rootUrl' in the Keycloak REST API. + required: false + + admin_url: + description: + - URL to the admin interface of the client + This is 'adminUrl' in the Keycloak REST API. + required: false + + base_url: + description: + - Default URL to use when the auth server needs to redirect or link back to the client + This is 'baseUrl' in the Keycloak REST API. + required: false + + enabled: + description: + - Is this client enabled or not? + required: false + + client_authenticator_type: + description: + - How do clients authenticate with the auth server? Either C(client-secret) or + C(client-jwt) can be chosen. When using C(client-secret), the module parameter + I(secret) can set it, while for C(client-jwt), you can use the keys C(use.jwks.url), + C(jwks.url), and C(jwt.credential.certificate) in the I(attributes) module parameter + to configure its behavior. + This is 'clientAuthenticatorType' in the Keycloak REST API. + required: false + choices: ['client-secret', 'client-jwt'] + + secret: + description: + - When using I(client_authenticator_type) C(client-secret) (the default), you can + specify a secret here (otherwise one will be generated if it does not exit). If + changing this secret, the module will not register a change currently (but the + changed secret will be saved). + required: false + + registration_access_token: + description: + - The registration access token provides access for clients to the client registration + service. + This is 'registrationAccessToken' in the Keycloak REST API. + required: false + + default_roles: + description: + - list of default roles for this client. If the client roles referenced do not exist + yet, they will be created. + This is 'defaultRoles' in the Keycloak REST API. + required: false + + redirect_uris: + description: + - Acceptable redirect URIs for this client. + This is 'redirectUris' in the Keycloak REST API. + required: false + + web_origins: + description: + - List of allowed CORS origins. + This is 'webOrigins' in the Keycloak REST API. + required: false + + not_before: + description: + - Revoke any tokens issued before this date for this client (this is a UNIX timestamp). + This is 'notBefore' in the Keycloak REST API. + required: false + + bearer_only: + description: + - The access type of this client is bearer-only. + This is 'bearerOnly' in the Keycloak REST API. + required: false + + consent_required: + description: + - If enabled, users have to consent to client access. + This is 'consentRequired' in the Keycloak REST API. + required: false + + standard_flow_enabled: + description: + - Enable standard flow for this client or not (OpenID connect). + This is 'standardFlowEnabled' in the Keycloak REST API. + required: false + + implicit_flow_enabled: + description: + - Enable implicit flow for this client or not (OpenID connect). + This is 'implictFlowEnabled' in the Keycloak REST API. + required: false + + direct_access_grants_enabled: + description: + - Are direct access grants enabled for this client or not (OpenID connect). + This is 'directAccessGrantsEnabled' in the Keycloak REST API. + required: false + + service_accounts_enabled: + description: + - Are service accounts enabled for this client or not (OpenID connect). + This is 'serviceAccountsEnabled' in the Keycloak REST API. + required: false + + authorization_services_enabled: + description: + - Are authorization services enabled for this client or not (OpenID connect). + This is 'authorizationServicesEnabled' in the Keycloak REST API. + required: false + + public_client: + description: + - Is the access type for this client public or not. + This is 'publicClient' in the Keycloak REST API. + required: false + + frontchannel_logout: + description: + - Is frontchannel logout enabled for this client or not. + This is 'frontchannelLogout' in the Keycloak REST API. + required: false + + protocol: + description: + - Type of client (either C(openid-connect) or C(saml). + required: false + choices: ['openid-connect', 'saml'] + + full_scope_allowed: + description: + - Is the "Full Scope Allowed" feature set for this client or not. + This is 'fullScopeAllowed' in the Keycloak REST API. + required: false + + node_re_registration_timeout: + description: + - Cluster node re-registration timeout for this client. + This is 'nodeReRegistrationTimeout' in the Keycloak REST API. + required: false + + registered_nodes: + description: + - dict of registered cluster nodes (with C(nodename) as the key and last registration + time as the value). + This is 'registeredNodes' in the Keycloak REST API. + required: false + + client_template: + description: + - Client template to use for this client. If it does not exist this field will silently + be dropped. + This is 'clientTemplate' in the Keycloak REST API. + required: false + + use_template_config: + description: + - Whether or not to use configuration from the I(client_template). + This is 'useTemplateConfig' in the Keycloak REST API. + required: false + + use_template_scope: + description: + - Whether or not to use scope configuration from the I(client_template). + This is 'useTemplateScope' in the Keycloak REST API. + required: false + + use_template_mappers: + description: + - Whether or not to use mapper configuration from the I(client_template). + This is 'useTemplateMappers' in the Keycloak REST API. + required: false + + surrogate_auth_required: + description: + - Whether or not surrogate auth is required. + This is 'surrogateAuthRequired' in the Keycloak REST API. + required: false + + authorization_settings: + description: + - a data structure defining the authorization settings for this client. For reference, + please see the Keycloak API docs at U(http://www.keycloak.org/docs-api/3.3/rest-api/index.html#_resourceserverrepresentation). + This is 'authorizationSettings' in the Keycloak REST API. + required: false + + protocol_mappers: + description: + - a list of dicts defining protocol mappers for this client. An example of one is given + in the examples section. + This is 'protocolMappers' in the Keycloak REST API. + required: false + + attributes: + description: + - A dict of further attributes for this client. This can contain various configuration + settings; an example is given in the examples section. + required: false + +extends_documentation_fragment: + - keycloak + +author: + - Eike Frost (@eikef) +''' + +EXAMPLES = ''' +- name: Create or update Keycloak client (minimal example) + local_action: + module: keycloak_client + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: present + +- name: Delete a Keycloak client + local_action: + module: keycloak_client + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + client_id: test + state: absent + +- name: Create or update a Keycloak client (with all the bells and whistles) + local_action: + module: keycloak_client + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + state: present + realm: master + client_id: test + id: d8b127a3-31f6-44c8-a7e4-4ab9a3e78d95 + name: this_is_a_test + description: Description of this wonderful client + root_url: https://www.example.com/ + admin_url: https://www.example.com/admin_url + base_url: basepath + enabled: True + client_authenticator_type: client-secret + secret: REALLYWELLKEPTSECRET + redirect_uris: + - https://www.example.com/* + - http://localhost:8888/ + web_origins: + - https://www.example.com/* + not_before: 1507825725 + bearer_only: False + consent_required: False + standard_flow_enabled: True + implicit_flow_enabled: False + direct_access_grants_enabled: False + service_accounts_enabled: False + authorization_services_enabled: False + public_client: False + frontchannel_logout: False + protocol: openid-connect + full_scope_allowed: false + node_re_registration_timeout: -1 + client_template: test + use_template_config: False + use_template_scope: false + use_template_mappers: no + registered_nodes: + node01.example.com: 1507828202 + registration_access_token: eyJWT_TOKEN + surrogate_auth_required: false + default_roles: + - test01 + - test02 + 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 + - config: + attribute.name: Role + attribute.nameformat: Basic + single: false + consentRequired: false + name: role list + protocol: saml + protocolMapper: saml-role-list-mapper + attributes: + saml.authnstatement: True + saml.client.signature: True + saml.force.post.binding: True + saml.server.signature: True + saml.signature.algorithm: RSA_SHA256 + saml.signing.certificate: CERTIFICATEHERE + saml.signing.private.key: PRIVATEKEYHERE + saml_force_name_id_format: False + saml_name_id_format: username + saml_signature_canonicalization_method: "http://www.w3.org/2001/10/xml-exc-c14n#" + user.info.response.signature.alg: RS256 + request.object.signature.alg: RS256 + use.jwks.url: true + jwks.url: JWKS_URL_FOR_CLIENT_AUTH_JWT + jwt.credential.certificate: JWT_CREDENTIAL_CERTIFICATE_FOR_CLIENT_AUTH +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: string + sample: "Client testclient has been updated" + +proposed: + description: client representation of proposed changes to client + returned: always + type: dict + sample: { + clientId: "test" + } +existing: + description: client representation of existing client (sample is truncated) + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +end_state: + description: client representation of client after module execution (sample is truncated) + returned: always + type: dict + sample: { + "adminUrl": "http://www.example.com/admin_url", + "attributes": { + "request.object.signature.alg": "RS256", + } + } +''' + +from ansible.module_utils.keycloak import KeycloakAPI, camel, keycloak_argument_spec +from ansible.module_utils.basic import AnsibleModule + + +def sanitize_cr(clientrep): + """ Removes probably sensitive details from a client representation + + :param clientrep: the clientrep dict to be sanitized + :return: sanitized clientrep dict + """ + result = clientrep.copy() + if 'secret' in result: + result['secret'] = 'no_log' + if 'attributes' in result: + if 'saml.signing.private.key' in result['attributes']: + result['attributes']['saml.signing.private.key'] = 'no_log' + return result + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + meta_args = dict( + state=dict(default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + + id=dict(type='str'), + client_id=dict(type='str'), + name=dict(type='str'), + description=dict(type='str'), + root_url=dict(type='str'), + admin_url=dict(type='str'), + base_url=dict(type='str'), + surrogate_auth_required=dict(type='bool'), + enabled=dict(type='bool'), + client_authenticator_type=dict(type='str', choices=['client-secret', 'client-jwt']), + secret=dict(type='str', no_log=True), + registration_access_token=dict(type='str'), + default_roles=dict(type='list'), + redirect_uris=dict(type='list'), + web_origins=dict(type='list'), + not_before=dict(type='int'), + bearer_only=dict(type='bool'), + consent_required=dict(type='bool'), + standard_flow_enabled=dict(type='bool'), + implicit_flow_enabled=dict(type='bool'), + direct_access_grants_enabled=dict(type='bool'), + service_accounts_enabled=dict(type='bool'), + authorization_services_enabled=dict(type='bool'), + public_client=dict(type='bool'), + frontchannel_logout=dict(type='bool'), + protocol=dict(type='str', choices=['openid-connect', 'saml']), + attributes=dict(type='dict'), + full_scope_allowed=dict(type='bool'), + node_re_registration_timeout=dict(type='int'), + registered_nodes=dict(type='dict'), + client_template=dict(type='str'), + use_template_config=dict(type='bool'), + use_template_scope=dict(type='bool'), + use_template_mappers=dict(type='bool'), + protocol_mappers=dict(type='list'), + authorization_settings=dict(type='dict'), + ) + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=([['client_id', 'id']])) + + result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={}) + + # Obtain access token, initialize API + kc = KeycloakAPI(module) + + realm = module.params.get('realm') + cid = module.params.get('id') + state = module.params.get('state') + + # convert module parameters to client representation parameters (if they belong in there) + client_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] + keycloak_argument_spec().keys() + # See whether the client already exists in Keycloak + if cid is None: + before_client = kc.get_client_by_clientid(module.params.get('client_id'), realm=realm) + if before_client is not None: + cid = before_client['id'] + else: + before_client = kc.get_client_by_id(cid, realm=realm) + + if before_client is None: + before_client = dict() + + # Build a proposed changeset from parameters given to this module + changeset = dict() + + for client_param in client_params: + # lists in the Keycloak API are sorted + new_param_value = module.params.get(client_param) + if isinstance(new_param_value, list): + try: + new_param_value = sorted(new_param_value) + except TypeError: + pass + changeset[camel(client_param)] = new_param_value + + # Whether creating or updating a client, take the before-state and merge the changeset into it + updated_client = before_client.copy() + updated_client.update(changeset) + + result['proposed'] = sanitize_cr(changeset) + result['existing'] = sanitize_cr(before_client) + + # If the client does not exist yet, before_client is still empty + if before_client == dict(): + if state == 'absent': + # do nothing and exit + if module._diff: + result['diff'] = dict(before='', after='') + result['msg'] = 'Client does not exist, doing nothing.' + module.exit_json(**result) + + # create new client + result['changed'] = True + if 'clientId' not in updated_client: + module.fail_json(msg='client_id needs to be specified when creating a new client') + + if module._diff: + result['diff'] = dict(before='', after=sanitize_cr(updated_client)) + + if module.check_mode: + module.exit_json(**result) + + kc.create_client(updated_client, realm=realm) + after_client = kc.get_client_by_clientid(updated_client['clientId'], realm=realm) + + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been created.' % updated_client['clientId'] + module.exit_json(**result) + else: + if state == 'present': + # update existing client + result['changed'] = True + if module.check_mode: + # We can only compare the current client with the proposed updates we have + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), + after=sanitize_cr(updated_client)) + + module.exit_json(**result) + + kc.update_client(cid, updated_client, realm=realm) + + after_client = kc.get_client_by_id(cid, realm=realm) + if before_client == after_client: + result['changed'] = False + if module._diff: + result['diff'] = dict(before=sanitize_cr(before_client), + after=sanitize_cr(after_client)) + result['end_state'] = sanitize_cr(after_client) + + result['msg'] = 'Client %s has been updated.' % updated_client['clientId'] + module.exit_json(**result) + else: + # Delete existing client + result['changed'] = True + if module._diff: + result['diff']['before'] = sanitize_cr(before_client) + result['diff']['after'] = '' + + if module.check_mode: + module.exit_json(**result) + + kc.delete_client(cid, realm=realm) + result['proposed'] = dict() + result['end_state'] = dict() + result['msg'] = 'Client %s has been deleted.' % before_client['clientId'] + 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 new file mode 100644 index 0000000000..4baf6a0a39 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/keycloak.py @@ -0,0 +1,59 @@ +# Copyright (c) 2017 Eike Frost +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = ''' +options: + auth_keycloak_url: + description: + - URL to the Keycloak instance. + required: true + + auth_client_id: + description: + - OpenID Connect I(client_id) to authenticate to the API with. + required: true + + auth_realm: + description: + - Keycloak realm name to authenticate to for API access. + required: true + + 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 + + auth_password: + description: + - Password to authenticate for API access with. + required: true + + validate_certs: + description: + - Verify TLS certificates (do not disable this in production). + required: false + default: True +'''