diff --git a/changelogs/fragments/11430-fix-keycloak-client-diff-for-flow-overrides.yml b/changelogs/fragments/11430-fix-keycloak-client-diff-for-flow-overrides.yml new file mode 100644 index 0000000000..53e4a1cb4e --- /dev/null +++ b/changelogs/fragments/11430-fix-keycloak-client-diff-for-flow-overrides.yml @@ -0,0 +1,2 @@ +bugfixes: + - keycloak_client - fix idempotency bug caused by ``null`` flow overrides value differences for non-existing flow overrides (https://github.com/ansible-collections/community.general/issues/11430, https://github.com/ansible-collections/community.general/pull/11455). \ No newline at end of file diff --git a/plugins/module_utils/identity/keycloak/_keycloak_utils.py b/plugins/module_utils/identity/keycloak/_keycloak_utils.py new file mode 100644 index 0000000000..593ae1f3c8 --- /dev/null +++ b/plugins/module_utils/identity/keycloak/_keycloak_utils.py @@ -0,0 +1,32 @@ +# 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 + +# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time. +# Do not use this from other collections or standalone plugins/modules! + +from __future__ import annotations + +import typing as t + + +def merge_settings_without_absent_nulls( + existing_settings: dict[str, t.Any], desired_settings: dict[str, t.Any] +) -> dict[str, t.Any]: + """ + Merges existing and desired settings into a new dictionary while excluding null values in desired settings that are absent in the existing settings. + This ensures idempotency by treating absent keys in existing settings and null values in desired settings as equivalent, preventing unnecessary updates. + + Args: + existing_settings (dict): Dictionary representing the current settings in Keycloak + desired_settings (dict): Dictionary representing the desired settings + + Returns: + dict: A new dictionary containing all entries from existing_settings and desired_settings, + excluding null values in desired_settings whose corresponding keys are not present in existing_settings + """ + + existing = existing_settings or {} + desired = desired_settings or {} + + return {**existing, **{k: v for k, v in desired.items() if v is not None or k in existing}} diff --git a/plugins/modules/keycloak_client.py b/plugins/modules/keycloak_client.py index 4ebf49cd57..82b1f6a5c6 100644 --- a/plugins/modules/keycloak_client.py +++ b/plugins/modules/keycloak_client.py @@ -768,6 +768,9 @@ import copy from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.identity.keycloak._keycloak_utils import ( + merge_settings_without_absent_nulls, +) from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( KeycloakAPI, KeycloakError, @@ -1364,15 +1367,17 @@ def main(): if client_param == "protocol_mappers": new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value] elif client_param == "authentication_flow_binding_overrides": - new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc) - elif client_param == "attributes" and "attributes" in before_client: - attributes_copy = copy.deepcopy(before_client["attributes"]) - # Merge client attributes while excluding null-valued attributes that are not present in Keycloak's response. - # This ensures idempotency by treating absent attributes and null attributes as equivalent. - attributes_copy.update( - {key: value for key, value in new_param_value.items() if value is not None or key in attributes_copy} + desired_flow_binding_overrides = flow_binding_from_dict_to_model(new_param_value, realm, kc) + existing_flow_binding_overrides = before_client.get("authenticationFlowBindingOverrides") + # ensures idempotency + new_param_value = merge_settings_without_absent_nulls( + existing_flow_binding_overrides, desired_flow_binding_overrides ) - new_param_value = attributes_copy + elif client_param == "attributes" and "attributes" in before_client: + desired_attributes = new_param_value + existing_attributes = copy.deepcopy(before_client["attributes"]) + # ensures idempotency + new_param_value = merge_settings_without_absent_nulls(existing_attributes, desired_attributes) elif client_param in ["clientScopesBehavior", "client_scopes_behavior"]: continue