keycloak(role): add realm support to generic updater

- Allow kc_object_kind='realm'
- Map endpoint to 'realms' and default lookup_field to 'id'
- Use realm-specific kcadm GET/UPDATE (no -r flag)
- Preserve immutables: id, realm
- Guard query-based ID resolution to non-realm objects

Context: fixing failure in 'Update REALM mail settings' task.
See: https://chatgpt.com/share/68affdb8-3d28-800f-8480-aa6a74000bf8
This commit is contained in:
2025-08-28 08:57:29 +02:00
parent 8baec17562
commit b9da6908ec

View File

@@ -1,20 +1,20 @@
# Generic updater for Keycloak client/component via kcadm. # Generic updater for Keycloak client/component/realm via kcadm.
# Flow: resolve ID → read current object → merge with desired → preserve immutable fields → update via stdin. # Flow: resolve ID → read current object → merge with desired → preserve immutable fields → update via stdin.
# #
# Required vars (pass via include): # Required vars (pass via include):
# - kc_object_kind: "client" | "component" # - kc_object_kind: "client" | "component" | "client-scope" | "realm"
# - kc_lookup_value: e.g., KEYCLOAK_CLIENT_ID or KEYCLOAK_LDAP_CMP_NAME # - 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 # - kc_desired: dict, e.g., KEYCLOAK_DICTIONARY_CLIENT or KEYCLOAK_DICTIONARY_LDAP
# #
# Optional: # Optional:
# - kc_lookup_field: override lookup field (defaults: clientId for client, name for component) # - 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_merge_path: if set (e.g. "config"), only that subkey is merged
# - kc_force_attrs: dict to force on the final payload (merged last) # - kc_force_attrs: dict to force on the final payload (merged last)
- name: Assert required vars - name: Assert required vars
assert: assert:
that: that:
- kc_object_kind in ['client','component','client-scope'] - kc_object_kind in ['client','component','client-scope','realm']
- kc_lookup_value is defined - kc_lookup_value is defined
- kc_desired is defined - kc_desired is defined
fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required." fail_msg: "kc_object_kind, kc_lookup_value, kc_desired are required."
@@ -26,11 +26,13 @@
{{ 'clients' if kc_object_kind == 'client' {{ 'clients' if kc_object_kind == 'client'
else 'components' if kc_object_kind == 'component' else 'components' if kc_object_kind == 'component'
else 'client-scopes' if kc_object_kind == 'client-scope' else 'client-scopes' if kc_object_kind == 'client-scope'
else 'realms' if kc_object_kind == 'realm'
else '' }} else '' }}
kc_lookup_field_eff: >- kc_lookup_field_eff: >-
{{ 'clientId' if kc_object_kind == 'client' {{ 'clientId' if kc_object_kind == 'client'
else (kc_lookup_field | default('name')) if kc_object_kind == 'component' else (kc_lookup_field | default('name')) if kc_object_kind == 'component'
else 'name' if kc_object_kind == 'client-scope' else 'name' if kc_object_kind == 'client-scope'
else 'id' if kc_object_kind == 'realm'
else '' }} else '' }}
- name: Resolve object id (direct when lookup_field is id) - name: Resolve object id (direct when lookup_field is id)
@@ -39,7 +41,7 @@
kc_obj_id: "{{ kc_lookup_value | string }}" kc_obj_id: "{{ kc_lookup_value | string }}"
- name: Resolve object id via query - name: Resolve object id via query
when: kc_lookup_field_eff != 'id' when: kc_lookup_field_eff != 'id' and kc_object_kind != 'realm'
shell: > shell: >
{% if kc_object_kind == 'client-scope' -%} {% if kc_object_kind == 'client-scope' -%}
{{ KEYCLOAK_EXEC_KCADM }} get client-scopes -r {{ KEYCLOAK_REALM }} --format json {{ KEYCLOAK_EXEC_KCADM }} get client-scopes -r {{ KEYCLOAK_REALM }} --format json
@@ -72,8 +74,11 @@
- name: Read current object - name: Read current object
shell: > shell: >
{{ KEYCLOAK_EXEC_KCADM }} get {{ kc_api }}/{{ kc_obj_id }} {% if kc_object_kind == 'realm' -%}
-r {{ KEYCLOAK_REALM }} --format json {{ 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 register: kc_cur
changed_when: false changed_when: false
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
@@ -141,12 +146,23 @@
}} }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
# Preserve immutables for client-scope
- name: Preserve immutable fields for client-scope - name: Preserve immutable fields for client-scope
when: kc_object_kind == 'client-scope' when: kc_object_kind == 'client-scope'
set_fact: set_fact:
desired_obj: "{{ desired_obj | combine({'id': cur_obj.id, 'name': cur_obj.name}, recursive=True) }}" 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) # Optional forced attributes (e.g., frontchannelLogout)
- name: Apply forced attributes (optional) - name: Apply forced attributes (optional)
@@ -156,8 +172,14 @@
- name: Update object via stdin - name: Update object via stdin
shell: | 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 - cat <<'JSON' | {{ KEYCLOAK_EXEC_KCADM }} update {{ kc_api }}/{{ kc_obj_id }} -r {{ KEYCLOAK_REALM }} -f -
{{ desired_obj | to_json }} {{ desired_obj | to_json }}
JSON JSON
{%- endif %}
async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}" async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}" poll: "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"