1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2026-02-04 07:51:50 +00:00
community.general/plugins/modules/keycloak_userprofile.py
2025-11-09 08:27:06 +01:00

737 lines
28 KiB
Python

#!/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
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
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
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
"""
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 copy import deepcopy
from urllib.parse import urlencode
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 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"),
},
),
"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"]),
"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")
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, value in changeset.items():
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 changeset_copy["config"][p] != userprofile["config"][p]:
changes += f"config.{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).
# 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()