# Generic updater for Keycloak client/component 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" # - kc_lookup_value: e.g., KEYCLOAK_CLIENT_ID or KEYCLOAK_LDAP_CMP_NAME # - 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) # - 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'] - kc_lookup_value is defined - kc_desired is defined fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required." - 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 '' }} 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 '' }} - 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' 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." - name: Read current object shell: > {{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} --format json 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" when: - kc_object_kind == 'component' - (kc_desired.providerId is defined) assert: that: - cur_obj.providerId == kc_desired.providerId fail_msg: > Refusing to update component '{{ cur_obj.name }}' (providerId={{ cur_obj.providerId }}) because desired providerId={{ kc_desired.providerId }}. Check your lookup/ID. - 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 }}" # Preserve immutables for client-scope - 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) }}" # 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: | cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} -f - {{ desired_obj | to_json }} JSON async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"