#!/usr/bin/python # -*- coding: utf-8 -*- # 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 from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = ''' --- module: keycloak_userprofile short_description: Allows managing Keycloak User Profiles description: - This module allows you to create, update, or delete Keycloak User Profiles via Keycloak API. You can also customize the "Unmanaged Attributes" with it. - 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/24.0.5/rest-api/index.html). For compatibility reasons, the module also accepts the camelCase versions of the options. version_added: "9.4.0" attributes: check_mode: support: full diff_mode: support: full options: state: description: - State of the User Profile provider. - On V(present), the User Profile provider will be created if it does not yet exist, or updated with the parameters you provide. - On V(absent), the User Profile provider will be removed if it exists. default: 'present' type: str choices: - present - absent parent_id: description: - The parent ID of the realm key. In practice the ID (name) of the realm. aliases: - parentId - realm type: str required: true provider_id: description: - The name of the provider ID for the key (supported value is V(declarative-user-profile)). aliases: - providerId choices: ['declarative-user-profile'] default: 'declarative-user-profile' type: str provider_type: description: - Component type for User Profile (only supported value is V(org.keycloak.userprofile.UserProfileProvider)). aliases: - providerType choices: ['org.keycloak.userprofile.UserProfileProvider'] default: org.keycloak.userprofile.UserProfileProvider type: str config: description: - The configuration of the User Profile Provider. type: dict required: false suboptions: kc_user_profile_config: description: - Define a declarative User Profile. See EXAMPLES for more context. aliases: - kcUserProfileConfig type: list elements: dict suboptions: attributes: description: - A list of attributes to be included in the User Profile. type: list elements: dict suboptions: name: description: - The name of the attribute. type: str required: true display_name: description: - The display name of the attribute. aliases: - displayName type: str required: true validations: description: - The validations to be applied to the attribute. type: dict suboptions: length: description: - The length validation for the attribute. type: dict suboptions: min: description: - The minimum length of the attribute. type: int max: description: - The maximum length of the attribute. type: int required: true email: description: - The email validation for the attribute. type: dict username_prohibited_characters: description: - The prohibited characters validation for the username attribute. type: dict aliases: - usernameProhibitedCharacters up_username_not_idn_homograph: description: - The validation to prevent IDN homograph attacks in usernames. type: dict aliases: - upUsernameNotIdnHomograph person_name_prohibited_characters: description: - The prohibited characters validation for person name attributes. type: dict aliases: - personNameProhibitedCharacters uri: description: - The URI validation for the attribute. type: dict pattern: description: - The pattern validation for the attribute using regular expressions. type: dict options: description: - Validation to ensure the attribute matches one of the provided options. type: dict annotations: description: - Annotations for the attribute. type: dict group: description: - Specifies the User Profile group where this attribute will be added. type: str permissions: description: - The permissions for viewing and editing the attribute. type: dict suboptions: view: description: - The roles that can view the attribute. - Supported values are V(admin) and V(user). type: list elements: str default: - admin - user edit: description: - The roles that can edit the attribute. - Supported values are V(admin) and V(user). type: list elements: str default: - admin - user multivalued: description: - Whether the attribute can have multiple values. type: bool default: false required: description: - The roles that require this attribute. type: dict suboptions: roles: description: - The roles for which this attribute is required. - Supported values are V(admin) and V(user). type: list elements: str default: - user groups: description: - A list of attribute groups to be included in the User Profile. type: list elements: dict suboptions: name: description: - The name of the group. type: str required: true display_header: description: - The display header for the group. aliases: - displayHeader type: str required: true display_description: description: - The display description for the group. aliases: - displayDescription type: str required: false annotations: description: - The annotations included in the group. type: dict required: false unmanaged_attribute_policy: description: - Policy for unmanaged attributes. aliases: - unmanagedAttributePolicy type: str choices: - ENABLED - ADMIN_EDIT - ADMIN_VIEW notes: - Currently, only a single V(declarative-user-profile) entry is supported for O(provider_id) (design of the Keyckoak API). However, there can be multiple O(config.kc_user_profile_config[].attributes[]) entries. extends_documentation_fragment: - community.general.keycloak - community.general.attributes author: - Eike Waldt (@yeoldegrove) ''' EXAMPLES = ''' - name: Create a Declarative User Profile with default settings community.general.keycloak_userprofile: state: present parent_id: master config: kc_user_profile_config: - attributes: - name: username displayName: ${username} validations: length: min: 3 max: 255 username_prohibited_characters: {} up_username_not_idn_homograph: {} annotations: {} permissions: view: - admin - user edit: [] multivalued: false - name: email displayName: ${email} validations: email: {} length: max: 255 annotations: {} required: roles: - user permissions: view: - admin - user edit: [] multivalued: false - name: firstName displayName: ${firstName} validations: length: max: 255 person_name_prohibited_characters: {} annotations: {} required: roles: - user permissions: view: - admin - user edit: [] multivalued: false - name: lastName displayName: ${lastName} validations: length: max: 255 person_name_prohibited_characters: {} annotations: {} required: roles: - user permissions: view: - admin - user edit: [] multivalued: false groups: - name: user-metadata displayHeader: User metadata displayDescription: Attributes, which refer to user metadata annotations: {} - name: Delete a Keycloak User Profile Provider keycloak_userprofile: state: absent parent_id: master # Unmanaged attributes are user attributes not explicitly defined in the User Profile # configuration. By default, unmanaged attributes are "Disabled" and are not # available from any context such as registration, account, and the # administration console. By setting "Enabled", unmanaged attributes are fully # recognized by the server and accessible through all contexts, useful if you are # starting migrating an existing realm to the declarative User Profile # and you don't have yet all user attributes defined in the User Profile configuration. - name: Enable Unmanaged Attributes community.general.keycloak_userprofile: state: present parent_id: master config: kc_user_profile_config: - unmanagedAttributePolicy: ENABLED # By setting "Only administrators can write", unmanaged attributes can be managed # only through the administration console and API, useful if you have already # defined any custom attribute that can be managed by users but you are unsure # about adding other attributes that should only be managed by administrators. - name: Enable ADMIN_EDIT on Unmanaged Attributes community.general.keycloak_userprofile: state: present parent_id: master config: kc_user_profile_config: - unmanagedAttributePolicy: ADMIN_EDIT # By setting `Only administrators can view`, unmanaged attributes are read-only # and only available through the administration console and API. - name: Enable ADMIN_VIEW on Unmanaged Attributes community.general.keycloak_userprofile: state: present parent_id: master config: kc_user_profile_config: - unmanagedAttributePolicy: ADMIN_VIEW ''' RETURN = ''' msg: description: The output message generated by the module. returned: always type: str sample: UserProfileProvider created successfully data: description: The data returned by the Keycloak API. returned: when state is present type: dict sample: {...} ''' 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 import json def remove_null_values(data): if isinstance(data, dict): # Recursively remove null values from dictionaries return {k: remove_null_values(v) for k, v in data.items() if v is not None} elif isinstance(data, list): # Recursively remove null values from lists return [remove_null_values(item) for item in data if item is not None] else: # Return the data if it's neither a dictionary nor a list return data def camel_recursive(data): if isinstance(data, dict): # Convert keys to camelCase and apply recursively return {camel(k): camel_recursive(v) for k, v in data.items()} elif isinstance(data, list): # Apply camelCase conversion to each item in the list return [camel_recursive(item) for item in data] else: # Return the data as is if it's not a dict or list return data def main(): argument_spec = keycloak_argument_spec() meta_args = dict( state=dict(type='str', choices=['present', 'absent'], default='present'), parent_id=dict(type='str', aliases=['parentId', 'realm'], required=True), provider_id=dict(type='str', aliases=['providerId'], default='declarative-user-profile', choices=['declarative-user-profile']), provider_type=dict( type='str', aliases=['providerType'], default='org.keycloak.userprofile.UserProfileProvider', choices=['org.keycloak.userprofile.UserProfileProvider'] ), config=dict( type='dict', required=False, options={ 'kc_user_profile_config': dict( type='list', aliases=['kcUserProfileConfig'], elements='dict', options={ 'attributes': dict( type='list', elements='dict', required=False, options={ 'name': dict(type='str', required=True), 'display_name': dict(type='str', aliases=['displayName'], required=True), 'validations': dict( type='dict', options={ 'length': dict( type='dict', options={ 'min': dict(type='int', required=False), 'max': dict(type='int', required=True) } ), 'email': dict(type='dict', required=False), 'username_prohibited_characters': dict(type='dict', aliases=['usernameProhibitedCharacters'], required=False), 'up_username_not_idn_homograph': dict(type='dict', aliases=['upUsernameNotIdnHomograph'], required=False), 'person_name_prohibited_characters': dict(type='dict', aliases=['personNameProhibitedCharacters'], required=False), 'uri': dict(type='dict', required=False), 'pattern': dict(type='dict', required=False), 'options': dict(type='dict', required=False) } ), 'annotations': dict(type='dict'), 'group': dict(type='str'), 'permissions': dict( type='dict', options={ 'view': dict(type='list', elements='str', default=['admin', 'user']), 'edit': dict(type='list', elements='str', default=['admin', 'user']) } ), 'multivalued': dict(type='bool', default=False), 'required': dict( type='dict', options={ 'roles': dict(type='list', elements='str', default=['user']) } ) } ), 'groups': dict( type='list', elements='dict', options={ 'name': dict(type='str', required=True), 'display_header': dict(type='str', aliases=['displayHeader'], required=True), 'display_description': dict(type='str', aliases=['displayDescription'], required=False), 'annotations': dict(type='dict', required=False) } ), 'unmanaged_attribute_policy': dict( type='str', aliases=['unmanagedAttributePolicy'], choices=['ENABLED', 'ADMIN_EDIT', 'ADMIN_VIEW'], required=False ) } ) } ) ) 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']])) # Initialize the result object. Only "changed" seems to have special # meaning for Ansible. result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) # This will include the current state of the realm userprofile if it is already # present. This is only used for diff-mode. before_realm_userprofile = {} before_realm_userprofile['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"] # 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 ] # Build a proposed changeset from parameters given to this module changeset = {} # Build the changeset with proper JSON serialization for kc_user_profile_config config = module.params.get('config') 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 proider_id # becomes providerId. It also handles some special cases, e.g. aliases. for component_param in component_params: # realm/parent_id parameter if component_param == 'realm' or component_param == 'parent_id': changeset['parent_id'] = module.params.get(component_param) changeset.pop(component_param, None) # complex parameters in config suboptions elif component_param == 'config': for config_param in config: # special parameter kc_user_profile_config if config_param in ('kcUserProfileConfig', 'kc_user_profile_config'): config_param_org = config_param # rename parameter to be accepted by Keycloak API config_param = 'kc.user.profile.config' # make sure no null values are passed to Keycloak API kc_user_profile_config = remove_null_values(config[config_param_org]) changeset[camel(component_param)][config_param] = [] if len(kc_user_profile_config) > 0: # convert aliases to camelCase kc_user_profile_config = camel_recursive(kc_user_profile_config) # rename validations to be accepted by Keycloak API if 'attributes' in kc_user_profile_config[0]: for attribute in kc_user_profile_config[0]['attributes']: if 'validations' in attribute: if 'usernameProhibitedCharacters' in attribute['validations']: attribute['validations']['username-prohibited-characters'] = ( attribute['validations'].pop('usernameProhibitedCharacters') ) if 'upUsernameNotIdnHomograph' in attribute['validations']: attribute['validations']['up-username-not-idn-homograph'] = ( attribute['validations'].pop('upUsernameNotIdnHomograph') ) if 'personNameProhibitedCharacters' in attribute['validations']: attribute['validations']['person-name-prohibited-characters'] = ( attribute['validations'].pop('personNameProhibitedCharacters') ) # special JSON parsing for kc_user_profile_config value = json.dumps(kc_user_profile_config[0]) changeset[camel(component_param)][config_param].append(value) # usual camelCase parameters else: changeset[camel(component_param)][camel(config_param)] = [] raw_value = module.params.get(component_param)[config_param] if isinstance(raw_value, bool): value = str(raw_value).lower() else: value = raw_value # Directly use the raw value changeset[camel(component_param)][camel(config_param)].append(value) # usual parameters else: new_param_value = module.params.get(component_param) changeset[camel(component_param)] = new_param_value # Make it easier to refer to current module parameters state = module.params.get('state') enabled = module.params.get('enabled') parent_id = module.params.get('parent_id') provider_type = module.params.get('provider_type') provider_id = module.params.get('provider_id') # Make a deep copy of the changeset. This is use when determining # changes to the current state. changeset_copy = deepcopy(changeset) # Get a list of all Keycloak components that are of userprofile provider type. realm_userprofiles = kc.get_components(urlencode(dict(type=provider_type, parent=parent_id)), parent_id) # If this component is present get its userprofile ID. Confusingly the userprofile ID is # also known as the Provider ID. userprofile_id = None # Track individual parameter changes changes = "" # This tells Ansible whether the userprofile 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 userprofile is # already present. for userprofile in realm_userprofiles: if provider_id == "declarative-user-profile": userprofile_id = userprofile['id'] changeset['id'] = userprofile_id changeset_copy['id'] = userprofile_id # Compare top-level parameters for param, value in changeset.items(): before_realm_userprofile[param] = userprofile[param] if changeset_copy[param] != userprofile[param] and param != 'config': changes += "%s: %s -> %s, " % (param, userprofile[param], changeset_copy[param]) result['changed'] = True # Compare parameters under the "config" userprofile for p, v in changeset_copy['config'].items(): before_realm_userprofile['config'][p] = userprofile['config'][p] if changeset_copy['config'][p] != userprofile['config'][p]: changes += "config.%s: %s -> %s, " % (p, userprofile['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 userprofile). if userprofile_id and state == 'present': if result['changed']: if module._diff: result['diff'] = dict(before=before_realm_userprofile, after=changeset_copy) if module.check_mode: result['msg'] = "Userprofile %s would be changed: %s" % (provider_id, changes.strip(", ")) else: kc.update_component(changeset, parent_id) result['msg'] = "Userprofile %s changed: %s" % (provider_id, changes.strip(", ")) else: result['msg'] = "Userprofile %s was in sync" % (provider_id) result['end_state'] = changeset_copy elif userprofile_id and state == 'absent': if module._diff: result['diff'] = dict(before=before_realm_userprofile, after={}) if module.check_mode: result['changed'] = True result['msg'] = "Userprofile %s would be deleted" % (provider_id) else: kc.delete_component(userprofile_id, parent_id) result['changed'] = True result['msg'] = "Userprofile %s deleted" % (provider_id) result['end_state'] = {} elif not userprofile_id and state == 'present': if module._diff: result['diff'] = dict(before={}, after=changeset_copy) if module.check_mode: result['changed'] = True result['msg'] = "Userprofile %s would be created" % (provider_id) else: kc.create_component(changeset, parent_id) result['changed'] = True result['msg'] = "Userprofile %s created" % (provider_id) result['end_state'] = changeset_copy elif not userprofile_id and state == 'absent': result['changed'] = False result['msg'] = "Userprofile %s not present" % (provider_id) result['end_state'] = {} module.exit_json(**result) if __name__ == '__main__': main()