#!/usr/bin/python # 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 annotations DOCUMENTATION = r""" module: keycloak_userprofile short_description: Allows managing Keycloak User Profiles description: - This module allows you to create, update, or delete Keycloak User Profiles using the 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 action_group: version_added: 10.2.0 options: state: description: - State of the User Profile provider. - On V(present), the User Profile provider is created if it does not yet exist, or updated with the parameters you provide. - On V(absent), the User Profile provider is 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 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 integer: description: - The integer validation for the attribute. type: dict version_added: 12.2.0 double: description: - The double validation for the attribute. type: dict version_added: 12.2.0 iso_date: description: - The iso-date validation for the attribute. type: dict aliases: - isoDate version_added: 12.2.0 local_date: description: - The local-date validation for the attribute. type: dict aliases: - localDate version_added: 12.2.0 multivalued: description: - The multivalued validation for the attribute. type: dict suboptions: min: description: - The minimum amount of values of the attribute. type: int max: description: - The maximum amount of values of the attribute. type: int required: true version_added: 12.2.0 annotations: description: - Annotations for the attribute. type: dict group: description: - Specifies the User Profile group where this attribute is 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 selector: description: - Selector when the attribute should be added. type: dict version_added: 12.2.0 suboptions: scopes: description: - Scopes to which the attribute should be added. type: list elements: str 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 annotations: description: - The annotations included in the group. type: dict 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.keycloak.actiongroup_keycloak - community.general.attributes author: - Eike Waldt (@yeoldegrove) """ EXAMPLES = r""" - 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 - name: testAttribute displayName: ${testAttribute} validations: integer: min: 0 max: 255 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 = r""" 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 """ import json from copy import deepcopy from urllib.parse import urlencode from ansible.module_utils.basic import AnsibleModule from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, KeycloakError, camel, get_token, keycloak_argument_spec, ) 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 is 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 is 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", options={ "kc_user_profile_config": dict( type="list", aliases=["kcUserProfileConfig"], elements="dict", options={ "attributes": dict( type="list", elements="dict", 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"), "max": dict(type="int", required=True)}, ), "email": dict(type="dict"), "username_prohibited_characters": dict( type="dict", aliases=["usernameProhibitedCharacters"] ), "up_username_not_idn_homograph": dict( type="dict", aliases=["upUsernameNotIdnHomograph"] ), "person_name_prohibited_characters": dict( type="dict", aliases=["personNameProhibitedCharacters"] ), "uri": dict(type="dict"), "pattern": dict(type="dict"), "options": dict(type="dict"), "integer": dict(type="dict"), "double": dict(type="dict"), "iso_date": dict(type="dict", aliases=["isoDate"]), "local_date": dict(type="dict", aliases=["localDate"]), "multivalued": dict( type="dict", options={ "min": dict(type="int", required=False), "max": dict(type="int", required=True), }, ), }, ), "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"])} ), "selector": dict(type="dict", options={"scopes": dict(type="list", elements="str")}), }, ), "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"]), "annotations": dict(type="dict"), }, ), "unmanaged_attribute_policy": dict( type="str", aliases=["unmanagedAttributePolicy"], choices=["ENABLED", "ADMIN_EDIT", "ADMIN_VIEW"], ), }, ) }, ), ) 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", "auth_client_id", "auth_client_secret"]] ), required_together=([["auth_username", "auth_password"]]), required_by={"refresh_token": "auth_realm"}, ) # 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") if "isoDate" in attribute["validations"]: attribute["validations"]["iso-date"] = attribute["validations"].pop("isoDate") if "localDate" in attribute["validations"]: attribute["validations"]["local-date"] = attribute["validations"].pop( "localDate" ) changeset[camel(component_param)][config_param].append(kc_user_profile_config[0]) # 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") 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_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 # keycloak returns kc.user.profile.config as a single JSON formatted string, so we have to deserialize it if "config" in userprofile and "kc.user.profile.config" in userprofile["config"]: userprofile["config"]["kc.user.profile.config"][0] = json.loads( userprofile["config"]["kc.user.profile.config"][0] ) # Compare top-level parameters for param in changeset: before_realm_userprofile[param] = userprofile[param] if changeset_copy[param] != userprofile[param] and param != "config": changes += f"{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 v != userprofile["config"][p]: changes += f"config.{p}: {userprofile['config'][p]} -> {v}, " 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). # keycloak expects kc.user.profile.config as a single JSON formatted string, so we have to serialize it if "config" in changeset and "kc.user.profile.config" in changeset["config"]: changeset["config"]["kc.user.profile.config"][0] = json.dumps(changeset["config"]["kc.user.profile.config"][0]) 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"] = f"Userprofile {provider_id} would be changed: {changes.strip(', ')}" else: kc.update_component(changeset, parent_id) result["msg"] = f"Userprofile {provider_id} changed: {changes.strip(', ')}" else: result["msg"] = f"Userprofile {provider_id} was in sync" 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"] = f"Userprofile {provider_id} would be deleted" else: kc.delete_component(userprofile_id, parent_id) result["changed"] = True result["msg"] = f"Userprofile {provider_id} deleted" 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"] = f"Userprofile {provider_id} would be created" else: kc.create_component(changeset, parent_id) result["changed"] = True result["msg"] = f"Userprofile {provider_id} created" result["end_state"] = changeset_copy elif not userprofile_id and state == "absent": result["changed"] = False result["msg"] = f"Userprofile {provider_id} not present" result["end_state"] = {} module.exit_json(**result) if __name__ == "__main__": main()