From ce7347f70b39a3c0834e4b85130c73663671f5a6 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Sat, 29 Nov 2025 23:09:34 +0100 Subject: [PATCH] Refactor Keycloak kcadm updates into custom Ansible module (see ChatGPT: https://chatgpt.com/share/692b6f0c-ebd4-800f-89e7-474d23c5dd32) --- .../library/keycloak_kcadm_update.py | 395 ++++++++++++++++++ .../tasks/update/01_client.yml | 12 +- .../tasks/update/02_mail_realm.yml | 21 +- .../tasks/update/03_mail_master.yml | 21 +- .../web-app-keycloak/tasks/update/05_ldap.yml | 34 +- .../tasks/update/07_userprofile.yml | 19 +- .../web-app-keycloak/tasks/update/_update.yml | 197 --------- 7 files changed, 463 insertions(+), 236 deletions(-) create mode 100644 roles/web-app-keycloak/library/keycloak_kcadm_update.py delete mode 100644 roles/web-app-keycloak/tasks/update/_update.yml diff --git a/roles/web-app-keycloak/library/keycloak_kcadm_update.py b/roles/web-app-keycloak/library/keycloak_kcadm_update.py new file mode 100644 index 00000000..652413c5 --- /dev/null +++ b/roles/web-app-keycloak/library/keycloak_kcadm_update.py @@ -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() diff --git a/roles/web-app-keycloak/tasks/update/01_client.yml b/roles/web-app-keycloak/tasks/update/01_client.yml index 0fc05c47..c4410917 100644 --- a/roles/web-app-keycloak/tasks/update/01_client.yml +++ b/roles/web-app-keycloak/tasks/update/01_client.yml @@ -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 }}" \ No newline at end of file diff --git a/roles/web-app-keycloak/tasks/update/02_mail_realm.yml b/roles/web-app-keycloak/tasks/update/02_mail_realm.yml index 712e40bc..fa98951a 100644 --- a/roles/web-app-keycloak/tasks/update/02_mail_realm.yml +++ b/roles/web-app-keycloak/tasks/update/02_mail_realm.yml @@ -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 }}" \ No newline at end of file + 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 }}" diff --git a/roles/web-app-keycloak/tasks/update/03_mail_master.yml b/roles/web-app-keycloak/tasks/update/03_mail_master.yml index 9e454544..45115562 100644 --- a/roles/web-app-keycloak/tasks/update/03_mail_master.yml +++ b/roles/web-app-keycloak/tasks/update/03_mail_master.yml @@ -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 }}" \ No newline at end of file + 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 }}" diff --git a/roles/web-app-keycloak/tasks/update/05_ldap.yml b/roles/web-app-keycloak/tasks/update/05_ldap.yml index 98a02e1a..0ebaf724 100644 --- a/roles/web-app-keycloak/tasks/update/05_ldap.yml +++ b/roles/web-app-keycloak/tasks/update/05_ldap.yml @@ -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 }}" diff --git a/roles/web-app-keycloak/tasks/update/07_userprofile.yml b/roles/web-app-keycloak/tasks/update/07_userprofile.yml index add80484..75d0e739 100644 --- a/roles/web-app-keycloak/tasks/update/07_userprofile.yml +++ b/roles/web-app-keycloak/tasks/update/07_userprofile.yml @@ -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 }}" diff --git a/roles/web-app-keycloak/tasks/update/_update.yml b/roles/web-app-keycloak/tasks/update/_update.yml deleted file mode 100644 index be5ada11..00000000 --- a/roles/web-app-keycloak/tasks/update/_update.yml +++ /dev/null @@ -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("") }}' - because providerId mismatch: - current='{{ cur_obj.providerId | default("") }}' - desired='{{ kc_desired.providerId | default("") }}'. - 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 }}"