#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2024, Björn Bösel # 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 from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- module: keycloak_component short_description: Allows administration of Keycloak components via Keycloak API version_added: 10.0.0 description: - This module allows the administration of Keycloak components via the Keycloak REST API. It requires access to the REST API via OpenID Connect; the user connecting and the realm being used must have the requisite access rights. In a default Keycloak installation, C(admin-cli) and an C(admin) user would work, as would a separate realm 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(https://www.keycloak.org/docs-api/latest/rest-api/index.html). Aliases are provided so camelCased versions can be used as well. attributes: check_mode: support: full diff_mode: support: full action_group: version_added: 10.2.0 options: state: description: - State of the Keycloak component. - On V(present), the component will be created (or updated if it exists already). - On V(absent), the component will be removed if it exists. choices: ['present', 'absent'] default: 'present' type: str name: description: - Name of the component to create. type: str required: true parent_id: description: - The parent_id of the component. In practice the ID (name) of the realm. type: str required: true provider_id: description: - The name of the "provider ID" for the key. type: str required: true provider_type: description: - The name of the "provider type" for the key. That is, V(org.keycloak.storage.UserStorageProvider), V(org.keycloak.userprofile.UserProfileProvider), ... - See U(https://www.keycloak.org/docs/latest/server_development/index.html#_providers). type: str required: true config: description: - Configuration properties for the provider. - Contents vary depending on the provider type. type: dict extends_documentation_fragment: - community.general.keycloak - community.general.keycloak.actiongroup_keycloak - community.general.attributes author: - Björn Bösel (@fivetide) ''' EXAMPLES = ''' - name: Manage Keycloak User Storage Provider community.general.keycloak_component: auth_keycloak_url: http://localhost:8080/auth auth_username: keycloak auth_password: keycloak auth_realm: master name: my storage provider state: present parent_id: some_realm provider_id: my storage provider_type: "org.keycloak.storage.UserStorageProvider" config: myCustomKey: "my_custom_key" cachePolicy: "NO_CACHE" enabled: true ''' RETURN = ''' end_state: description: Representation of the keycloak_component after module execution. returned: on success type: dict contains: id: description: ID of the component. type: str returned: when O(state=present) sample: 5b7ec13f-99da-46ad-8326-ab4c73cf4ce4 name: description: Name of the component. type: str returned: when O(state=present) sample: mykey parentId: description: ID of the realm this key belongs to. type: str returned: when O(state=present) sample: myrealm providerId: description: The ID of the key provider. type: str returned: when O(state=present) sample: rsa providerType: description: The type of provider. type: str returned: when O(state=present) config: description: component configuration. type: dict ''' from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ keycloak_argument_spec, get_token, KeycloakError from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.six.moves.urllib.parse import urlencode from copy import deepcopy def main(): argument_spec = keycloak_argument_spec() meta_args = dict( state=dict(type='str', default='present', choices=['present', 'absent']), name=dict(type='str', required=True), parent_id=dict(type='str', required=True), provider_id=dict(type='str', required=True), provider_type=dict(type='str', required=True), config=dict( type='dict', ) ) argument_spec.update(meta_args) module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True, required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), required_together=([['auth_realm', 'auth_username', 'auth_password']])) result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) # This will include the current state of the component if it is already # present. This is only used for diff-mode. before_component = {} before_component['config'] = {} # Obtain access token, initialize API try: connection_header = get_token(module.params) except KeycloakError as e: module.fail_json(msg=str(e)) kc = KeycloakAPI(module, connection_header) params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "parent_id"] # Filter and map the parameters names that apply to the role component_params = [x for x in module.params if x not in params_to_ignore and module.params.get(x) is not None] provider_type = module.params.get("provider_type") # Build a proposed changeset from parameters given to this module changeset = {} changeset['config'] = {} # Generate a JSON payload for Keycloak Admin API from the module # parameters. Parameters that do not belong to the JSON payload (e.g. # "state" or "auth_keycloal_url") have been filtered away earlier (see # above). # # This loop converts Ansible module parameters (snake-case) into # Keycloak-compatible format (camel-case). For example private_key # becomes privateKey. # # It also converts bool, str and int parameters into lists with a single # entry of 'str' type. Bool values are also lowercased. This is required # by Keycloak. # for component_param in component_params: if component_param == 'config': for config_param in module.params.get('config'): changeset['config'][camel(config_param)] = [] raw_value = module.params.get('config')[config_param] if isinstance(raw_value, bool): value = str(raw_value).lower() else: value = str(raw_value) changeset['config'][camel(config_param)].append(value) else: # No need for camelcase in here as these are one word parameters new_param_value = module.params.get(component_param) changeset[camel(component_param)] = new_param_value # Make a deep copy of the changeset. This is use when determining # changes to the current state. changeset_copy = deepcopy(changeset) # Make it easier to refer to current module parameters name = module.params.get('name') force = module.params.get('force') state = module.params.get('state') enabled = module.params.get('enabled') provider_id = module.params.get('provider_id') provider_type = module.params.get('provider_type') parent_id = module.params.get('parent_id') # Get a list of all Keycloak components that are of keyprovider type. current_components = kc.get_components(urlencode(dict(type=provider_type)), parent_id) # If this component is present get its key ID. Confusingly the key ID is # also known as the Provider ID. component_id = None # Track individual parameter changes changes = "" # This tells Ansible whether the key was changed (added, removed, modified) result['changed'] = False # Loop through the list of components. If we encounter a component whose # name matches the value of the name parameter then assume the key is # already present. for component in current_components: if component['name'] == name: component_id = component['id'] changeset['id'] = component_id changeset_copy['id'] = component_id # Compare top-level parameters for param, value in changeset.items(): before_component[param] = component[param] if changeset_copy[param] != component[param] and param != 'config': changes += "%s: %s -> %s, " % (param, component[param], changeset_copy[param]) result['changed'] = True # Compare parameters under the "config" key for p, v in changeset_copy['config'].items(): try: before_component['config'][p] = component['config'][p] or [] except KeyError: before_component['config'][p] = [] if changeset_copy['config'][p] != component['config'][p]: changes += "config.%s: %s -> %s, " % (p, component['config'][p], changeset_copy['config'][p]) result['changed'] = True # Check all the possible states of the resource and do what is needed to # converge current state with desired state (create, update or delete # the key). if component_id and state == 'present': if result['changed']: if module._diff: result['diff'] = dict(before=before_component, after=changeset_copy) if module.check_mode: result['msg'] = "Component %s would be changed: %s" % (name, changes.strip(", ")) else: kc.update_component(changeset, parent_id) result['msg'] = "Component %s changed: %s" % (name, changes.strip(", ")) else: result['msg'] = "Component %s was in sync" % (name) result['end_state'] = changeset_copy elif component_id and state == 'absent': if module._diff: result['diff'] = dict(before=before_component, after={}) if module.check_mode: result['changed'] = True result['msg'] = "Component %s would be deleted" % (name) else: kc.delete_component(component_id, parent_id) result['changed'] = True result['msg'] = "Component %s deleted" % (name) result['end_state'] = {} elif not component_id and state == 'present': if module._diff: result['diff'] = dict(before={}, after=changeset_copy) if module.check_mode: result['changed'] = True result['msg'] = "Component %s would be created" % (name) else: kc.create_component(changeset, parent_id) result['changed'] = True result['msg'] = "Component %s created" % (name) result['end_state'] = changeset_copy elif not component_id and state == 'absent': result['changed'] = False result['msg'] = "Component %s not present" % (name) result['end_state'] = {} module.exit_json(**result) if __name__ == '__main__': main()