#!/usr/bin/python # Copyright (c) 2019, INSPQ (@elfelip) # GNU General Public License v3.0+ (see COPYING 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_user short_description: Create and configure a user in Keycloak description: - This module creates, removes, or updates Keycloak users. version_added: 7.1.0 options: auth_username: aliases: [] realm: description: - The name of the realm in which is the client. default: master type: str username: description: - Username for the user. required: true type: str id: description: - ID of the user on the Keycloak server if known. type: str enabled: description: - Enabled user. type: bool email_verified: description: - Check the validity of user email. default: false type: bool aliases: - emailVerified first_name: description: - The user's first name. required: false type: str aliases: - firstName last_name: description: - The user's last name. required: false type: str aliases: - lastName email: description: - User email. required: false type: str federation_link: description: - Federation Link. required: false type: str aliases: - federationLink service_account_client_id: description: - Description of the client Application. required: false type: str aliases: - serviceAccountClientId client_consents: description: - Client Authenticator Type. type: list elements: dict default: [] aliases: - clientConsents suboptions: client_id: description: - Client ID of the client role. Not the technical ID of the client. type: str required: true aliases: - clientId roles: description: - List of client roles to assign to the user. type: list required: true elements: str groups: description: - List of groups for the user. - Groups can be referenced by their name, like V(staff), or their path, like V(/staff/engineering). The path syntax allows you to reference subgroups, which is not possible otherwise. - Using the path is possible since community.general 10.6.0. type: list elements: dict default: [] suboptions: name: description: - Name of the group. type: str state: description: - Control whether the user must be member of this group or not. choices: ["present", "absent"] default: present type: str credentials: description: - User credentials. default: [] type: list elements: dict suboptions: type: description: - Credential type. type: str required: true value: description: - Value of the credential. type: str required: true temporary: description: - If V(true), the users are required to reset their credentials at next login. type: bool default: false required_actions: description: - RequiredActions user Auth. default: [] type: list elements: str aliases: - requiredActions federated_identities: description: - List of IDPs of user. default: [] type: list elements: str aliases: - federatedIdentities attributes: description: - List of user attributes. required: false type: list elements: dict suboptions: name: description: - Name of the attribute. type: str values: description: - Values for the attribute as list. type: list elements: str state: description: - Control whether the attribute must exists or not. choices: ["present", "absent"] default: present type: str access: description: - List user access. required: false type: dict disableable_credential_types: description: - List user Credential Type. default: [] type: list elements: str aliases: - disableableCredentialTypes origin: description: - User origin. required: false type: str self: description: - User self administration. required: false type: str state: description: - Control whether the user should exists or not. choices: ["present", "absent"] default: present type: str force: description: - If V(true), allows to remove user and recreate it. type: bool default: false extends_documentation_fragment: - community.general.keycloak - community.general.keycloak.actiongroup_keycloak - community.general.attributes attributes: check_mode: support: full diff_mode: support: full action_group: version_added: 10.2.0 notes: - The module does not modify the user ID of an existing user. author: - Philippe Gauthier (@elfelip) """ EXAMPLES = r""" - name: Create a user user1 community.general.keycloak_user: auth_keycloak_url: http://localhost:8080/auth auth_username: admin auth_password: password realm: master username: user1 firstName: user1 lastName: user1 email: user1 enabled: true emailVerified: false credentials: - type: password value: password temporary: false attributes: - name: attr1 values: - value1 state: present - name: attr2 values: - value2 state: absent groups: - name: group1 state: present state: present - name: Re-create a User community.general.keycloak_user: auth_keycloak_url: http://localhost:8080/auth auth_username: admin auth_password: password realm: master username: user1 firstName: user1 lastName: user1 email: user1 enabled: true emailVerified: false credentials: - type: password value: password temporary: false attributes: - name: attr1 values: - value1 state: present - name: attr2 values: - value2 state: absent groups: - name: group1 state: present state: present - name: Re-create a User community.general.keycloak_user: auth_keycloak_url: http://localhost:8080/auth auth_username: admin auth_password: password realm: master username: user1 firstName: user1 lastName: user1 email: user1 enabled: true emailVerified: false credentials: - type: password value: password temporary: false attributes: - name: attr1 values: - value1 state: present - name: attr2 values: - value2 state: absent groups: - name: group1 state: present state: present force: true - name: Remove User community.general.keycloak_user: auth_keycloak_url: http://localhost:8080/auth auth_username: admin auth_password: password realm: master username: user1 state: absent """ RETURN = r""" proposed: description: Representation of the proposed user. returned: on success type: dict existing: description: Representation of the existing user. returned: on success type: dict end_state: description: Representation of the user after module execution. returned: on success type: dict user_created: description: Indicates whether a user was created. returned: in success type: bool version_added: 12.0.0 """ from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, camel, keycloak_argument_spec, get_token, KeycloakError, is_struct_included, ) from ansible.module_utils.basic import AnsibleModule import copy def main(): argument_spec = keycloak_argument_spec() argument_spec["auth_username"]["aliases"] = [] credential_spec = dict( type=dict(type="str", required=True), value=dict(type="str", required=True, no_log=True), temporary=dict(type="bool", default=False), ) client_consents_spec = dict( client_id=dict(type="str", required=True, aliases=["clientId"]), roles=dict(type="list", elements="str", required=True), ) attributes_spec = dict( name=dict(type="str"), values=dict(type="list", elements="str"), state=dict(type="str", choices=["present", "absent"], default="present"), ) groups_spec = dict(name=dict(type="str"), state=dict(type="str", choices=["present", "absent"], default="present")) meta_args = dict( realm=dict(type="str", default="master"), self=dict(type="str"), id=dict(type="str"), username=dict(type="str", required=True), first_name=dict(type="str", aliases=["firstName"]), last_name=dict(type="str", aliases=["lastName"]), email=dict(type="str"), enabled=dict(type="bool"), email_verified=dict(type="bool", default=False, aliases=["emailVerified"]), federation_link=dict(type="str", aliases=["federationLink"]), service_account_client_id=dict(type="str", aliases=["serviceAccountClientId"]), attributes=dict(type="list", elements="dict", options=attributes_spec), access=dict(type="dict"), groups=dict(type="list", default=[], elements="dict", options=groups_spec), disableable_credential_types=dict( type="list", default=[], aliases=["disableableCredentialTypes"], elements="str" ), required_actions=dict(type="list", default=[], aliases=["requiredActions"], elements="str"), credentials=dict(type="list", default=[], elements="dict", options=credential_spec), federated_identities=dict(type="list", default=[], aliases=["federatedIdentities"], elements="str"), client_consents=dict( type="list", default=[], aliases=["clientConsents"], elements="dict", options=client_consents_spec ), origin=dict(type="str"), state=dict(choices=["absent", "present"], default="present"), force=dict(type="bool", default=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", "auth_client_id", "auth_client_secret"]] ), required_together=([["auth_username", "auth_password"]]), required_by={"refresh_token": "auth_realm"}, ) result = dict(changed=False, msg="", diff={}, proposed={}, existing={}, end_state={}) # 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) realm = module.params.get("realm") state = module.params.get("state") force = module.params.get("force") username = module.params.get("username") groups = module.params.get("groups") # Filter and map the parameters names that apply to the user user_params = [ x for x in module.params if x not in list(keycloak_argument_spec().keys()) + ["state", "realm", "force", "groups"] and module.params.get(x) is not None ] before_user = kc.get_user_by_username(username=username, realm=realm) if before_user is None: before_user = {} changeset = {} for param in user_params: new_param_value = module.params.get(param) if param == "attributes" and param in before_user: old_value = kc.convert_keycloak_user_attributes_dict_to_module_list(attributes=before_user["attributes"]) else: old_value = before_user[param] if param in before_user else None if new_param_value != old_value: if old_value is not None and param == "attributes": for old_attribute in old_value: old_attribute_found = False for new_attribute in new_param_value: if new_attribute["name"] == old_attribute["name"]: old_attribute_found = True if not old_attribute_found: new_param_value.append(copy.deepcopy(old_attribute)) if isinstance(new_param_value, dict): changeset[camel(param)] = copy.deepcopy(new_param_value) else: changeset[camel(param)] = new_param_value # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_user = copy.deepcopy(before_user) desired_user.update(changeset) result["proposed"] = changeset result["existing"] = before_user # Default values for user_created result["user_created"] = False changed = False # Cater for when it doesn't exist (an empty dict) if state == "absent": if not before_user: # Do nothing and exit if module._diff: result["diff"] = dict(before="", after="") result["changed"] = False result["end_state"] = {} result["msg"] = "Role does not exist, doing nothing." module.exit_json(**result) else: # Delete user kc.delete_user(user_id=before_user["id"], realm=realm) result["msg"] = f"User {before_user['username']} deleted" changed = True else: after_user = {} if force and before_user: # If the force option is set to true # Delete the existing user kc.delete_user(user_id=before_user["id"], realm=realm) if not before_user or force: # Process a creation changed = True if username is None: module.fail_json(msg="username must be specified when creating a new user") if module._diff: result["diff"] = dict(before="", after=desired_user) if module.check_mode: # Set user_created flag explicit for check_mode # create_user could have failed, but we don't know for sure until we try to create the user.' result["user_created"] = True module.exit_json(**result) # Create the user after_user = kc.create_user(userrep=desired_user, realm=realm) result["msg"] = f"User {desired_user['username']} created" # Add user ID to new representation desired_user["id"] = after_user["id"] # Set user_created flag result["user_created"] = True else: excludes = [ "access", "notBefore", "createdTimestamp", "totp", "credentials", "disableableCredentialTypes", "groups", "clientConsents", "federatedIdentities", "requiredActions", ] # Add user ID to new representation desired_user["id"] = before_user["id"] # Compare users if not ( is_struct_included(desired_user, before_user, excludes) ): # If the new user does not introduce a change to the existing user # Update the user after_user = kc.update_user(userrep=desired_user, realm=realm) changed = True # set user groups if kc.update_user_groups_membership(userrep=desired_user, groups=groups, realm=realm): changed = True # Get the user groups after_user["groups"] = kc.get_user_groups(user_id=desired_user["id"], realm=realm) result["end_state"] = after_user if changed: result["msg"] = f"User {desired_user['username']} updated" else: result["msg"] = f"No changes made for user {desired_user['username']}" result["changed"] = changed module.exit_json(**result) if __name__ == "__main__": main()