Repository: ansible-collections/community.general

Issue:
[Keycloak] Identity Provider unchanged mappers are deleted
### Summary

When updating an IdP with a deterministic list of mappers, unchanged mappers are deleted because no changes are detected.



### Issue Type

Bug Report

### Component Name

keycloak_identity_provider

### Ansible Version

```console (paste below)
$ ansible --version
ansible [core 2.14.2]
  config file = [...]/ansible.cfg
  configured module search path = ['[...]/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = [...]/venv/lib/python3.10/site-packages/ansible
  ansible collection location =[...]/{{ ANSIBLE_HOME ~ "/collections" }}
  executable location = [...]/venv/bin/ansible
  python version = 3.10.9 (main, Dec 19 2022, 17:35:49) [GCC 12.2.0] ([...]/venv/bin/python)
  jinja version = 3.1.2
  libyaml = True
```


### Community.general Version

```console (paste below)
$ ansible-galaxy collection list community.general
Collection        Version
----------------- -------
community.general 6.3.0
```


### Configuration

```console (paste below)
$ ansible-config dump --only-changed

```


### OS / Environment

_No response_

### Steps to Reproduce

Given email is already created and given_name is to be created, here is an example mappers attribute for keycloak_identity_provider module:
```yaml (paste below)
    mappers:
    - name: email
      identityProviderMapper: oidc-user-attribute-idp-mapper
      config:
        claim: email
        user.attribute: email
        syncMode: FORCE
    - name: first_name
      identityProviderMapper: oidc-user-attribute-idp-mapper
      config:
        claim: given_name
        user.attribute: firstName
        syncMode: FORCE
```


### Expected Results

All mappers are present in Keycloak IdP if only one exists

### Actual Results

Existing mapper is deleted 

### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/keycloak_identity_provider.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/keycloak_identity_provider.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @eikef @laurpaum @ndclt
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Proposed fix in PR #6003 
cc @mattock
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Still experiencing this issue in the latest community.general 
There is a fix in #7418, it's missing a thorough review. If anyone wants to help, please do so!

Resolving patch:
diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py
--- a/plugins/modules/keycloak_identity_provider.py
+++ b/plugins/modules/keycloak_identity_provider.py
@@ -542,10 +542,14 @@ def main():
                     old_mapper = dict()
             new_mapper = old_mapper.copy()
             new_mapper.update(change)
-            if new_mapper != old_mapper:
-                if changeset.get('mappers') is None:
-                    changeset['mappers'] = list()
-                changeset['mappers'].append(new_mapper)
+
+            if changeset.get('mappers') is None:
+                changeset['mappers'] = list()
+            # eventually this holds all desired mappers, unchanged, modified and newly added
+            changeset['mappers'].append(new_mapper)
+
+        # ensure idempotency in case module.params.mappers is not sorted by name
+        changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('id') if x.get('name') is None else x['name'])
 
     # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis)
     desired_idp = before_idp.copy()
@@ -612,10 +616,17 @@ def main():
             # do the update
             desired_idp = desired_idp.copy()
             updated_mappers = desired_idp.pop('mappers', [])
+            original_mappers = list(before_idp.get('mappers', []))
+
             kc.update_identity_provider(desired_idp, realm)
             for mapper in updated_mappers:
                 if mapper.get('id') is not None:
-                    kc.update_identity_provider_mapper(mapper, alias, realm)
+                    # only update existing if there is a change
+                    for i, orig in enumerate(original_mappers):
+                        if mapper['id'] == orig['id']:
+                            del original_mappers[i]
+                            if mapper != orig:
+                                kc.update_identity_provider_mapper(mapper, alias, realm)
                 else:
                     if mapper.get('identityProviderAlias') is None:
                         mapper['identityProviderAlias'] = alias


Test patch:
diff --git a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml
--- a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml
+++ b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml
@@ -35,14 +35,14 @@
       syncMode: FORCE
     mappers:
       - name: "first_name"
-        identityProviderAlias: "oidc-idp"
+        identityProviderAlias: "{{ idp }}"
         identityProviderMapper: "oidc-user-attribute-idp-mapper"
         config:
           claim: "first_name"
           user.attribute: "first_name"
           syncMode: "INHERIT"
       - name: "last_name"
-        identityProviderAlias: "oidc-idp"
+        identityProviderAlias: "{{ idp }}"
         identityProviderMapper: "oidc-user-attribute-idp-mapper"
         config:
           claim: "last_name"
@@ -84,14 +84,14 @@
       syncMode: FORCE
     mappers:
       - name: "first_name"
-        identityProviderAlias: "oidc-idp"
+        identityProviderAlias: "{{ idp }}"
         identityProviderMapper: "oidc-user-attribute-idp-mapper"
         config:
           claim: "first_name"
           user.attribute: "first_name"
           syncMode: "INHERIT"
       - name: "last_name"
-        identityProviderAlias: "oidc-idp"
+        identityProviderAlias: "{{ idp }}"
         identityProviderMapper: "oidc-user-attribute-idp-mapper"
         config:
           claim: "last_name"
@@ -109,7 +109,7 @@
     that:
       - result is not changed
 
-- name: Update existing identity provider (with change)
+- name: Update existing identity provider (with change, no mapper change)
   community.general.keycloak_identity_provider:
     auth_keycloak_url: "{{ url }}"
     auth_realm: "{{ admin_realm }}"
@@ -132,6 +132,109 @@
       - result.existing.enabled == true
       - result.end_state.enabled == false
 
+- name: Update existing identity provider (delete mapper)
+  community.general.keycloak_identity_provider:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    alias: "{{ idp }}"
+    state: present
+    mappers:
+      - name: "first_name"
+        identityProviderAlias: "{{ idp }}"
+        identityProviderMapper: "oidc-user-attribute-idp-mapper"
+        config:
+          claim: "first_name"
+          user.attribute: "first_name"
+          syncMode: "INHERIT"
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert identity provider updated
+  assert:
+    that:
+      - result is changed
+      - result.existing.mappers | length == 2
+      - result.end_state.mappers | length == 1
+      - result.end_state.mappers[0].name == "first_name"
+
+- name: Update existing identity provider (add mapper)
+  community.general.keycloak_identity_provider:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    alias: "{{ idp }}"
+    state: present
+    mappers:
+      - name: "last_name"
+        identityProviderAlias: "{{ idp }}"
+        identityProviderMapper: "oidc-user-attribute-idp-mapper"
+        config:
+          claim: "last_name"
+          user.attribute: "last_name"
+          syncMode: "INHERIT"
+      - name: "first_name"
+        identityProviderAlias: "{{ idp }}"
+        identityProviderMapper: "oidc-user-attribute-idp-mapper"
+        config:
+          claim: "first_name"
+          user.attribute: "first_name"
+          syncMode: "INHERIT"
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert identity provider updated
+  assert:
+    that:
+      - result is changed
+      - result.existing.mappers | length == 1
+      - result.end_state.mappers | length == 2
+
+- name: Update existing identity provider (no change, test mapper idempotency)
+  community.general.keycloak_identity_provider:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    alias: "{{ idp }}"
+    state: present
+    mappers:
+      - name: "last_name"
+        identityProviderAlias: "{{ idp }}"
+        identityProviderMapper: "oidc-user-attribute-idp-mapper"
+        config:
+          claim: "last_name"
+          user.attribute: "last_name"
+          syncMode: "INHERIT"
+      - name: "first_name"
+        identityProviderAlias: "{{ idp }}"
+        identityProviderMapper: "oidc-user-attribute-idp-mapper"
+        config:
+          claim: "first_name"
+          user.attribute: "first_name"
+          syncMode: "INHERIT"
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert identity provider updated
+  assert:
+    that:
+      - result is not changed
+
 - name: Delete existing identity provider
   community.general.keycloak_identity_provider:
     auth_keycloak_url: "{{ url }}"