1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00
community.general/plugins/modules/keycloak_userprofile.py
patchback[bot] 5f7a3ac896
[PR #8651/529af498 backport][stable-9] keycloak_userprofile: new module (#8848)
keycloak_userprofile: new module (#8651)

keycloak_userprofile: new keycloak module to manage user profiles (#8651)

(cherry picked from commit 529af4984c)

Co-authored-by: Eike Waldt <waldt@b1-systems.de>
2024-09-09 14:06:42 +02:00

732 lines
31 KiB
Python

#!/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()