mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-11-30 08:06:47 +00:00
Refactor Keycloak kcadm updates into custom Ansible module (see ChatGPT: https://chatgpt.com/share/692b6f0c-ebd4-800f-89e7-474d23c5dd32)
This commit is contained in:
395
roles/web-app-keycloak/library/keycloak_kcadm_update.py
Normal file
395
roles/web-app-keycloak/library/keycloak_kcadm_update.py
Normal file
@@ -0,0 +1,395 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: keycloak_kcadm_update
|
||||
|
||||
short_description: Create or update Keycloak clients/components/client-scopes/realms via kcadm.
|
||||
|
||||
description:
|
||||
- Generic "create or update" module for Keycloak objects using kcadm.
|
||||
- Resolves the object by a lookup field, reads current state if it exists,
|
||||
deep-merges the desired state on top (optionally only a sub-path),
|
||||
preserves immutable fields, applies forced attributes and then
|
||||
updates or creates the object.
|
||||
|
||||
options:
|
||||
object_kind:
|
||||
description: Kind of the Keycloak object.
|
||||
type: str
|
||||
required: True
|
||||
choices: [client, component, client-scope, realm]
|
||||
lookup_value:
|
||||
description: Value to look up the object (e.g. clientId, component name, realm id).
|
||||
type: str
|
||||
required: True
|
||||
desired:
|
||||
description: Desired object dictionary.
|
||||
type: dict
|
||||
required: True
|
||||
lookup_field:
|
||||
description:
|
||||
- Lookup field name.
|
||||
- Defaults depend on object_kind:
|
||||
- client -> clientId
|
||||
- component -> name
|
||||
- client-scope -> name
|
||||
- realm -> id
|
||||
type: str
|
||||
required: False
|
||||
merge_path:
|
||||
description:
|
||||
- If set (e.g. C(config)), only this subkey is merged into the current object.
|
||||
- If omitted, the whole object is merged.
|
||||
type: str
|
||||
required: False
|
||||
force_attrs:
|
||||
description:
|
||||
- Attributes that are always applied last on the final payload.
|
||||
type: dict
|
||||
required: False
|
||||
kcadm_exec:
|
||||
description:
|
||||
- Command to execute kcadm.
|
||||
- E.g. C(docker exec -i keycloak /opt/keycloak/bin/kcadm.sh).
|
||||
type: str
|
||||
required: True
|
||||
realm:
|
||||
description:
|
||||
- Realm name used for non-realm objects.
|
||||
type: str
|
||||
required: False
|
||||
assert_mode:
|
||||
description:
|
||||
- If true, additional safety checks are applied (e.g. providerId match for components).
|
||||
type: bool
|
||||
required: False
|
||||
default: True
|
||||
|
||||
author:
|
||||
- Your Name
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Create or update a Keycloak client (merge full object)
|
||||
keycloak_kcadm_update:
|
||||
object_kind: client
|
||||
lookup_value: "{{ KEYCLOAK_CLIENT_ID }}"
|
||||
desired: "{{ KEYCLOAK_DICTIONARY_CLIENT }}"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
|
||||
- name: Create or update LDAP component (merge only config)
|
||||
keycloak_kcadm_update:
|
||||
object_kind: component
|
||||
lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
|
||||
desired: "{{ KEYCLOAK_DICTIONARY_LDAP }}"
|
||||
merge_path: config
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
force_attrs:
|
||||
parentId: "{{ KEYCLOAK_REALM }}"
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
changed:
|
||||
description: Whether the object was created or updated.
|
||||
type: bool
|
||||
returned: always
|
||||
object_exists:
|
||||
description: Whether the object was found by the lookup.
|
||||
type: bool
|
||||
returned: always
|
||||
object_id:
|
||||
description: Resolved object id (if exists).
|
||||
type: str
|
||||
returned: always
|
||||
result:
|
||||
description: The final payload that was sent to Keycloak.
|
||||
type: dict
|
||||
returned: always
|
||||
'''
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
import json
|
||||
import subprocess
|
||||
from copy import deepcopy
|
||||
|
||||
|
||||
def run_kcadm(module, cmd, ignore_rc=False):
|
||||
"""Run a shell command for kcadm."""
|
||||
try:
|
||||
rc = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
check=not ignore_rc,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
stdout = rc.stdout.decode('utf-8').strip()
|
||||
stderr = rc.stderr.decode('utf-8').strip()
|
||||
return rc.returncode, stdout, stderr
|
||||
except Exception as e:
|
||||
module.fail_json(msg="Failed to run kcadm command", cmd=cmd, error=str(e))
|
||||
|
||||
|
||||
def deep_merge(a, b):
|
||||
"""Recursive dict merge similar to Ansible's combine(recursive=True)."""
|
||||
result = deepcopy(a)
|
||||
for k, v in b.items():
|
||||
if (
|
||||
k in result
|
||||
and isinstance(result[k], dict)
|
||||
and isinstance(v, dict)
|
||||
):
|
||||
result[k] = deep_merge(result[k], v)
|
||||
else:
|
||||
result[k] = deepcopy(v)
|
||||
return result
|
||||
|
||||
|
||||
def get_api_and_lookup_field(object_kind, lookup_field):
|
||||
if object_kind == 'client':
|
||||
api = 'clients'
|
||||
default_lookup = 'clientId'
|
||||
elif object_kind == 'component':
|
||||
api = 'components'
|
||||
default_lookup = 'name'
|
||||
elif object_kind == 'client-scope':
|
||||
api = 'client-scopes'
|
||||
default_lookup = 'name'
|
||||
elif object_kind == 'realm':
|
||||
api = 'realms'
|
||||
default_lookup = 'id'
|
||||
else:
|
||||
api = ''
|
||||
default_lookup = ''
|
||||
return api, (lookup_field or default_lookup)
|
||||
|
||||
|
||||
def resolve_object_id(module, object_kind, api, lookup_field, lookup_value, realm, kcadm_exec):
|
||||
"""Return (object_id, exists_flag)."""
|
||||
if lookup_field == 'id':
|
||||
obj_id = str(lookup_value).strip()
|
||||
if not obj_id:
|
||||
return '', False
|
||||
return obj_id, True
|
||||
|
||||
if object_kind == 'realm':
|
||||
# For realms we treat lookup_value as id/realm name; we will verify on get.
|
||||
return str(lookup_value), True
|
||||
|
||||
if object_kind == 'client-scope':
|
||||
cmd = f"{kcadm_exec} get client-scopes -r {realm} --format json"
|
||||
rc, out, err = run_kcadm(module, cmd, ignore_rc=True)
|
||||
if rc != 0 or not out:
|
||||
return '', False
|
||||
try:
|
||||
scopes = json.loads(out)
|
||||
except Exception:
|
||||
return '', False
|
||||
for obj in scopes:
|
||||
if obj.get(lookup_field) == lookup_value:
|
||||
return obj.get('id', ''), True
|
||||
return '', False
|
||||
|
||||
# Generic path (client, component via query)
|
||||
cmd = (
|
||||
f"{kcadm_exec} get {api} -r {realm} "
|
||||
f"--query '{lookup_field}={lookup_value}' "
|
||||
f"--fields id --format json"
|
||||
)
|
||||
rc, out, err = run_kcadm(module, cmd, ignore_rc=True)
|
||||
if rc != 0 or not out:
|
||||
return '', False
|
||||
try:
|
||||
data = json.loads(out)
|
||||
if not data:
|
||||
return '', False
|
||||
return data[0].get('id', ''), True
|
||||
except Exception:
|
||||
return '', False
|
||||
|
||||
|
||||
def get_current_object(module, object_kind, api, object_id, realm, kcadm_exec):
|
||||
if object_kind == 'realm':
|
||||
cmd = f"{kcadm_exec} get {api}/{object_id} --format json"
|
||||
else:
|
||||
cmd = f"{kcadm_exec} get {api}/{object_id} -r {realm} --format json"
|
||||
rc, out, err = run_kcadm(module, cmd)
|
||||
try:
|
||||
return json.loads(out)
|
||||
except Exception as e:
|
||||
module.fail_json(msg="Failed to parse current Keycloak object JSON", error=str(e), stdout=out)
|
||||
|
||||
|
||||
def send_update(module, object_kind, api, object_id, realm, kcadm_exec, payload):
|
||||
payload_json = json.dumps(payload)
|
||||
if object_kind == 'realm':
|
||||
cmd = f"cat <<'JSON' | {kcadm_exec} update {api}/{object_id} -f -\n{payload_json}\nJSON"
|
||||
else:
|
||||
cmd = f"cat <<'JSON' | {kcadm_exec} update {api}/{object_id} -r {realm} -f -\n{payload_json}\nJSON"
|
||||
rc, out, err = run_kcadm(module, cmd, ignore_rc=True)
|
||||
return rc, out, err
|
||||
|
||||
|
||||
def send_create(module, object_kind, api, realm, kcadm_exec, payload):
|
||||
payload_json = json.dumps(payload)
|
||||
if object_kind == 'realm':
|
||||
cmd = f"cat <<'JSON' | {kcadm_exec} create {api} -f -\n{payload_json}\nJSON"
|
||||
else:
|
||||
cmd = f"cat <<'JSON' | {kcadm_exec} create {api} -r {realm} -f -\n{payload_json}\nJSON"
|
||||
rc, out, err = run_kcadm(module, cmd, ignore_rc=True)
|
||||
return rc, out, err
|
||||
|
||||
|
||||
def run_module():
|
||||
module_args = dict(
|
||||
object_kind=dict(type='str', required=True),
|
||||
lookup_value=dict(type='str', required=True),
|
||||
desired=dict(type='dict', required=True),
|
||||
lookup_field=dict(type='str', required=False, default=None),
|
||||
merge_path=dict(type='str', required=False, default=None),
|
||||
force_attrs=dict(type='dict', required=False, default=None),
|
||||
kcadm_exec=dict(type='str', required=True),
|
||||
realm=dict(type='str', required=False, default=None),
|
||||
assert_mode=dict(type='bool', required=False, default=True),
|
||||
)
|
||||
|
||||
result = dict(
|
||||
changed=False,
|
||||
object_exists=False,
|
||||
object_id='',
|
||||
result={},
|
||||
)
|
||||
|
||||
module = AnsibleModule(
|
||||
argument_spec=module_args,
|
||||
supports_check_mode=False,
|
||||
)
|
||||
|
||||
object_kind = module.params['object_kind']
|
||||
lookup_value = module.params['lookup_value']
|
||||
desired = module.params['desired'] or {}
|
||||
lookup_field = module.params['lookup_field']
|
||||
merge_path = module.params['merge_path']
|
||||
force_attrs = module.params['force_attrs'] or {}
|
||||
kcadm_exec = module.params['kcadm_exec']
|
||||
realm = module.params['realm']
|
||||
assert_mode = module.params['assert_mode']
|
||||
|
||||
if object_kind != 'realm' and not realm:
|
||||
module.fail_json(msg="Parameter 'realm' is required for non-realm objects.")
|
||||
|
||||
api, eff_lookup_field = get_api_and_lookup_field(object_kind, lookup_field)
|
||||
if not api:
|
||||
module.fail_json(msg="Unsupported object_kind", object_kind=object_kind)
|
||||
|
||||
object_id, exists = resolve_object_id(
|
||||
module, object_kind, api, eff_lookup_field, lookup_value, realm, kcadm_exec
|
||||
)
|
||||
|
||||
result['object_exists'] = exists
|
||||
result['object_id'] = object_id
|
||||
|
||||
# CREATE PATH (no existing object)
|
||||
if not exists or not object_id:
|
||||
desired_obj = deepcopy(desired)
|
||||
|
||||
# Drop unsupported fields for components (e.g. subComponents)
|
||||
if object_kind == 'component':
|
||||
desired_obj.pop('subComponents', None)
|
||||
|
||||
# Apply forced attributes (common behavior)
|
||||
if force_attrs:
|
||||
desired_obj = deep_merge(desired_obj, force_attrs)
|
||||
|
||||
rc, out, err = send_create(
|
||||
module, object_kind, api, realm, kcadm_exec, desired_obj
|
||||
)
|
||||
if rc != 0:
|
||||
module.fail_json(
|
||||
msg="Failed to create Keycloak object",
|
||||
rc=rc, stdout=out, stderr=err, payload=desired_obj
|
||||
)
|
||||
|
||||
result['changed'] = True
|
||||
result['result'] = desired_obj
|
||||
module.exit_json(**result)
|
||||
|
||||
# UPDATE PATH (object exists)
|
||||
cur_obj = get_current_object(module, object_kind, api, object_id, realm, kcadm_exec)
|
||||
|
||||
# Optional safety check: providerId must match for components
|
||||
if assert_mode and object_kind == 'component':
|
||||
cur_provider = cur_obj.get('providerId', '')
|
||||
des_provider = desired.get('providerId', '')
|
||||
if cur_provider and des_provider and cur_provider != des_provider:
|
||||
module.fail_json(
|
||||
msg="Refusing to update component due to providerId mismatch",
|
||||
current_providerId=cur_provider,
|
||||
desired_providerId=des_provider,
|
||||
)
|
||||
|
||||
# Build merge payload (full or subpath)
|
||||
if merge_path:
|
||||
merge_payload = {
|
||||
merge_path: deepcopy(desired.get(merge_path, {}))
|
||||
}
|
||||
else:
|
||||
merge_payload = deepcopy(desired)
|
||||
|
||||
desired_obj = deep_merge(cur_obj, merge_payload)
|
||||
|
||||
# Preserve immutable fields
|
||||
if object_kind == 'client':
|
||||
for k in ['id', 'clientId']:
|
||||
if k in cur_obj:
|
||||
desired_obj[k] = cur_obj[k]
|
||||
|
||||
elif object_kind == 'component':
|
||||
for k in ['id', 'providerId', 'providerType', 'parentId']:
|
||||
if k in cur_obj:
|
||||
desired_obj[k] = cur_obj[k]
|
||||
# Drop unsupported fields such as subComponents
|
||||
desired_obj.pop('subComponents', None)
|
||||
|
||||
elif object_kind == 'client-scope':
|
||||
for k in ['id', 'name']:
|
||||
if k in cur_obj:
|
||||
desired_obj[k] = cur_obj[k]
|
||||
|
||||
elif object_kind == 'realm':
|
||||
for k in ['id', 'realm']:
|
||||
if k in cur_obj:
|
||||
desired_obj[k] = cur_obj[k]
|
||||
|
||||
# Apply forced attributes (last)
|
||||
if force_attrs:
|
||||
desired_obj = deep_merge(desired_obj, force_attrs)
|
||||
|
||||
# If nothing changed logically, we could diff & short-circuit.
|
||||
# For simplicity we always send update and rely on Keycloak to no-op.
|
||||
rc, out, err = send_update(
|
||||
module, object_kind, api, object_id, realm, kcadm_exec, desired_obj
|
||||
)
|
||||
if rc != 0:
|
||||
module.fail_json(
|
||||
msg="Failed to update Keycloak object",
|
||||
rc=rc, stdout=out, stderr=err, payload=desired_obj
|
||||
)
|
||||
|
||||
result['changed'] = True
|
||||
result['result'] = desired_obj
|
||||
module.exit_json(**result)
|
||||
|
||||
|
||||
def main():
|
||||
run_module()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -37,4 +37,14 @@
|
||||
| list | first | default({}) ).attributes | default({}) )
|
||||
| combine({'frontchannel.logout.url': KEYCLOAK_FRONTCHANNEL_LOGOUT_URL}, recursive=True)
|
||||
}}
|
||||
include_tasks: _update.yml
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "{{ kc_object_kind }}"
|
||||
lookup_value: "{{ kc_lookup_value }}"
|
||||
desired: "{{ kc_desired }}"
|
||||
force_attrs: "{{ kc_force_attrs }}"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
@@ -1,10 +1,13 @@
|
||||
- name: "Update {{ KEYCLOAK_REALM }} REALM mail settings from realm dictionary"
|
||||
include_tasks: _update.yml
|
||||
vars:
|
||||
kc_object_kind: "realm"
|
||||
kc_lookup_field: "id"
|
||||
kc_lookup_value: "{{ KEYCLOAK_REALM }}"
|
||||
kc_desired:
|
||||
smtpServer: "{{ KEYCLOAK_DICTIONARY_REALM.smtpServer | default({}, true) }}"
|
||||
kc_merge_path: "smtpServer"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "realm"
|
||||
lookup_field: "id"
|
||||
lookup_value: "{{ KEYCLOAK_REALM }}"
|
||||
desired:
|
||||
smtpServer: "{{ KEYCLOAK_DICTIONARY_REALM.smtpServer }}"
|
||||
merge_path: "smtpServer"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
- name: "Update Master REALM mail settings from realm dictionary"
|
||||
include_tasks: _update.yml
|
||||
vars:
|
||||
kc_object_kind: "realm"
|
||||
kc_lookup_field: "id"
|
||||
kc_lookup_value: "master"
|
||||
kc_desired:
|
||||
smtpServer: "{{ KEYCLOAK_DICTIONARY_REALM.smtpServer | default({}, true) }}"
|
||||
kc_merge_path: "smtpServer"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "realm"
|
||||
lookup_field: "id"
|
||||
lookup_value: "master"
|
||||
desired:
|
||||
smtpServer: "{{ KEYCLOAK_DICTIONARY_REALM.smtpServer }}"
|
||||
merge_path: "smtpServer"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
- name: "Update REALM settings (merge LDAP component .config)"
|
||||
include_tasks: _update.yml
|
||||
vars:
|
||||
kc_object_kind: "component"
|
||||
kc_lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
|
||||
kc_desired: >-
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "component"
|
||||
lookup_value: "{{ KEYCLOAK_LDAP_CMP_NAME }}"
|
||||
desired: >-
|
||||
{{
|
||||
(
|
||||
KEYCLOAK_DICTIONARY_REALM.components['org.keycloak.storage.UserStorageProvider']
|
||||
@@ -11,7 +10,11 @@
|
||||
| list | first
|
||||
)
|
||||
}}
|
||||
kc_merge_path: "config"
|
||||
merge_path: "config"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Resolve LDAP component id
|
||||
shell: >
|
||||
@@ -129,11 +132,11 @@
|
||||
|
||||
- name: Update 'ldap-roles' mapper config (merge only .config)
|
||||
when: (grp_mapper_id.stdout | trim) not in ["", "null"]
|
||||
vars:
|
||||
kc_object_kind: "component"
|
||||
kc_lookup_field: "id"
|
||||
kc_lookup_value: "{{ grp_mapper_id.stdout | trim }}"
|
||||
kc_desired: >-
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "component"
|
||||
lookup_field: "id"
|
||||
lookup_value: "{{ grp_mapper_id.stdout | trim }}"
|
||||
desired: >-
|
||||
{{
|
||||
desired_group_mapper
|
||||
| combine({
|
||||
@@ -143,5 +146,10 @@
|
||||
'providerId': 'group-ldap-mapper'
|
||||
}, recursive=True)
|
||||
}}
|
||||
kc_merge_path: "config"
|
||||
include_tasks: _update.yml
|
||||
merge_path: "config"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
|
||||
@@ -34,10 +34,15 @@
|
||||
changed_when: false
|
||||
|
||||
- name: "Update UserProfileProvider component (merge kc.user.profile.config)"
|
||||
vars:
|
||||
kc_object_kind: "component"
|
||||
kc_lookup_field: "id"
|
||||
kc_lookup_value: "{{ kc_userprofile_id.stdout | trim }}"
|
||||
kc_desired: "{{ kc_userprofile_tpl }}"
|
||||
kc_merge_path: "config"
|
||||
include_tasks: _update.yml
|
||||
keycloak_kcadm_update:
|
||||
object_kind: "component"
|
||||
lookup_field: "id"
|
||||
lookup_value: "{{ kc_userprofile_id.stdout | trim }}"
|
||||
desired: "{{ kc_userprofile_tpl }}"
|
||||
merge_path: "config"
|
||||
kcadm_exec: "{{ KEYCLOAK_EXEC_KCADM }}"
|
||||
realm: "{{ KEYCLOAK_REALM }}"
|
||||
assert_mode: "{{ MODE_ASSERT }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
# Generic updater for Keycloak client/component/realm via kcadm.
|
||||
# Flow: resolve ID → read current object → merge with desired → preserve immutable fields → update via stdin.
|
||||
#
|
||||
# Required vars (pass via include):
|
||||
# - kc_object_kind: "client" | "component" | "client-scope" | "realm"
|
||||
# - kc_lookup_value: e.g., KEYCLOAK_CLIENT_ID or KEYCLOAK_LDAP_CMP_NAME or KEYCLOAK_REALM
|
||||
# - kc_desired: dict, e.g., KEYCLOAK_DICTIONARY_CLIENT or KEYCLOAK_DICTIONARY_LDAP
|
||||
#
|
||||
# Optional:
|
||||
# - kc_lookup_field: override lookup field (defaults: clientId for client, name for component, id for realm)
|
||||
# - kc_merge_path: if set (e.g. "config"), only that subkey is merged
|
||||
# - kc_force_attrs: dict to force on the final payload (merged last)
|
||||
|
||||
- name: Assert required vars
|
||||
assert:
|
||||
that:
|
||||
- kc_object_kind in ['client','component','client-scope','realm']
|
||||
- kc_lookup_value is defined
|
||||
- kc_desired is defined
|
||||
fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required."
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: Derive API endpoint and lookup field
|
||||
set_fact:
|
||||
kc_api: >-
|
||||
{{ 'clients' if kc_object_kind == 'client'
|
||||
else 'components' if kc_object_kind == 'component'
|
||||
else 'client-scopes' if kc_object_kind == 'client-scope'
|
||||
else 'realms' if kc_object_kind == 'realm'
|
||||
else '' }}
|
||||
kc_lookup_field_eff: >-
|
||||
{{ 'clientId' if kc_object_kind == 'client'
|
||||
else (kc_lookup_field | default('name')) if kc_object_kind == 'component'
|
||||
else 'name' if kc_object_kind == 'client-scope'
|
||||
else 'id' if kc_object_kind == 'realm'
|
||||
else '' }}
|
||||
|
||||
- name: Resolve object id (direct when lookup_field is id)
|
||||
when: kc_lookup_field_eff == 'id'
|
||||
set_fact:
|
||||
kc_obj_id: "{{ kc_lookup_value | string }}"
|
||||
|
||||
- name: Resolve object id via query
|
||||
when: kc_lookup_field_eff != 'id' and kc_object_kind != 'realm'
|
||||
shell: >
|
||||
{% if kc_object_kind == 'client-scope' -%}
|
||||
{{ KEYCLOAK_EXEC_KCADM }} get client-scopes -r {{ KEYCLOAK_REALM }} --format json
|
||||
| jq -r '.[] | select(.{{ kc_lookup_field_eff }}=="{{ kc_lookup_value }}") | .id' | head -n1
|
||||
{%- else -%}
|
||||
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}
|
||||
-r {{ KEYCLOAK_REALM }}
|
||||
--query '{{ kc_lookup_field_eff }}={{ kc_lookup_value }}'
|
||||
--fields id --format json | jq -r '.[0].id'
|
||||
{%- endif %}
|
||||
register: kc_obj_id_cmd
|
||||
changed_when: false
|
||||
|
||||
- name: Normalize resolved id to a plain string
|
||||
set_fact:
|
||||
kc_obj_id: >-
|
||||
{{
|
||||
kc_obj_id
|
||||
if kc_lookup_field_eff == 'id'
|
||||
else (kc_obj_id_cmd.stdout | default('') | trim)
|
||||
}}
|
||||
|
||||
- name: Fail if object not found
|
||||
assert:
|
||||
that:
|
||||
- (kc_obj_id | trim) != ''
|
||||
- (kc_obj_id | trim) != 'null'
|
||||
fail_msg: "{{ kc_object_kind | capitalize }} '{{ kc_lookup_value }}' not found."
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: Read current object
|
||||
shell: >
|
||||
{% if kc_object_kind == 'realm' -%}
|
||||
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} --format json
|
||||
{%- else -%}
|
||||
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} --format json
|
||||
{%- endif %}
|
||||
register: kc_cur
|
||||
changed_when: false
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Parse current object
|
||||
set_fact:
|
||||
cur_obj: "{{ kc_cur.stdout | from_json }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: "Safety check: providerId must match when updating a component"
|
||||
assert:
|
||||
that:
|
||||
- (cur_obj.providerId | default('') ) == (kc_desired.providerId | default('') )
|
||||
fail_msg: >-
|
||||
Refusing to update component '{{ kc_obj_id | default("<unknown>") }}'
|
||||
because providerId mismatch:
|
||||
current='{{ cur_obj.providerId | default("<undefined>") }}'
|
||||
desired='{{ kc_desired.providerId | default("<undefined>") }}'.
|
||||
when: MODE_ASSERT | default(true) | bool
|
||||
|
||||
- name: Prepare merge payload (subpath)
|
||||
when: kc_merge_path is defined and (kc_merge_path | length) > 0
|
||||
set_fact:
|
||||
merge_payload: "{{ { (kc_merge_path): (kc_desired[kc_merge_path] | default({}, true)) } }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Prepare merge payload (full object)
|
||||
when: kc_merge_path is not defined or (kc_merge_path | length) == 0
|
||||
set_fact:
|
||||
merge_payload: "{{ kc_desired }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Build desired object (base merge)
|
||||
set_fact:
|
||||
desired_obj: "{{ cur_obj | combine(merge_payload, recursive=True) }}"
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
# Preserve immutable fields
|
||||
- name: Preserve immutable fields for client
|
||||
when: kc_object_kind == 'client'
|
||||
set_fact:
|
||||
desired_obj: >-
|
||||
{{
|
||||
desired_obj
|
||||
| combine({
|
||||
'id': cur_obj.id,
|
||||
'clientId': cur_obj.clientId
|
||||
}, recursive=True)
|
||||
}}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Preserve immutable fields for component
|
||||
when: kc_object_kind == 'component'
|
||||
set_fact:
|
||||
desired_obj: >-
|
||||
{{
|
||||
desired_obj
|
||||
| combine({
|
||||
'id': cur_obj.id,
|
||||
'providerId': cur_obj.providerId,
|
||||
'providerType': cur_obj.providerType,
|
||||
'parentId': cur_obj.parentId
|
||||
}, recursive=True)
|
||||
}}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Drop unsupported fields for components (e.g. subComponents)
|
||||
when: kc_object_kind == 'component'
|
||||
set_fact:
|
||||
desired_obj: >-
|
||||
{{
|
||||
desired_obj
|
||||
| dict2items
|
||||
| rejectattr('key', 'equalto', 'subComponents')
|
||||
| list
|
||||
| items2dict
|
||||
}}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
- name: Preserve immutable fields for client-scope
|
||||
when: kc_object_kind == 'client-scope'
|
||||
set_fact:
|
||||
desired_obj: "{{ desired_obj | combine({'id': cur_obj.id, 'name': cur_obj.name}, recursive=True) }}"
|
||||
|
||||
- name: Preserve immutable fields for realm
|
||||
when: kc_object_kind == 'realm'
|
||||
set_fact:
|
||||
desired_obj: >-
|
||||
{{
|
||||
desired_obj
|
||||
| combine({
|
||||
'id': cur_obj.id,
|
||||
'realm': cur_obj.realm
|
||||
}, recursive=True)
|
||||
}}
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
|
||||
# Optional forced attributes (e.g., frontchannelLogout)
|
||||
- name: Apply forced attributes (optional)
|
||||
when: kc_force_attrs is defined
|
||||
set_fact:
|
||||
desired_obj: "{{ desired_obj | combine(kc_force_attrs, recursive=True) }}"
|
||||
|
||||
- name: Update object via stdin
|
||||
shell: |
|
||||
{% if kc_object_kind == 'realm' -%}
|
||||
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -f -
|
||||
{{ desired_obj | to_json }}
|
||||
JSON
|
||||
{%- else -%}
|
||||
cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} -f -
|
||||
{{ desired_obj | to_json }}
|
||||
JSON
|
||||
{%- endif %}
|
||||
#async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
|
||||
#poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
|
||||
Reference in New Issue
Block a user