Repository: ansible-collections/community.general

Issue:
Add support for keycloak User Federation
### Summary

I would love to have support for configuring User Federation and it's (ldap) mappers in keycloak. 

### Issue Type

Feature Idea

### Component Name

keycloak_user_federation

### Additional Information

This would allow for a playbook that install's and configures a keycloak server with ldap integration. 


### Code of Conduct

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


Discussion:
Files identified in the description:
None

If these files are inaccurate, 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/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
!component =plugins/modules/identity/keycloak/
Files identified in the description:
* [`plugins/modules/identity/keycloak/`](https://github.com/ansible-collections/community.general/blob/master/plugins/modules/identity/keycloak/)

If these files are inaccurate, 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/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @eikef @ndclt
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py
--- a/plugins/module_utils/identity/keycloak/keycloak.py
+++ b/plugins/module_utils/identity/keycloak/keycloak.py
@@ -83,6 +83,9 @@
 URL_IDENTITY_PROVIDER_MAPPERS = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers"
 URL_IDENTITY_PROVIDER_MAPPER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}/mappers/{id}"
 
+URL_COMPONENTS = "{url}/admin/realms/{realm}/components"
+URL_COMPONENT = "{url}/admin/realms/{realm}/components/{id}"
+
 
 def keycloak_argument_spec():
     """
@@ -1601,3 +1604,93 @@ def delete_identity_provider_mapper(self, mid, alias, realm='master'):
         except Exception as e:
             self.module.fail_json(msg='Unable to delete mapper %s for identity provider %s in realm %s: %s'
                                       % (mid, alias, realm, str(e)))
+
+    def get_components(self, filter=None, realm='master'):
+        """ Fetch representations for components in a realm
+        :param realm: realm to be queried
+        :param filter: search filter
+        :return: list of representations for components
+        """
+        comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm)
+        if filter is not None:
+            comps_url += '?%s' % filter
+
+        try:
+            return json.loads(to_native(open_url(comps_url, method='GET', headers=self.restheaders,
+                                                 validate_certs=self.validate_certs).read()))
+        except ValueError as e:
+            self.module.fail_json(msg='API returned incorrect JSON when trying to obtain list of components for realm %s: %s'
+                                      % (realm, str(e)))
+        except Exception as e:
+            self.module.fail_json(msg='Could not obtain list of components for realm %s: %s'
+                                      % (realm, str(e)))
+
+    def get_component(self, cid, realm='master'):
+        """ Fetch component representation from a realm using its cid.
+        If the component does not exist, None is returned.
+        :param cid: Unique ID of the component to fetch.
+        :param realm: Realm in which the component resides; default 'master'.
+        """
+        comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid)
+        try:
+            return json.loads(to_native(open_url(comp_url, method="GET", headers=self.restheaders,
+                                                 validate_certs=self.validate_certs).read()))
+        except HTTPError as e:
+            if e.code == 404:
+                return None
+            else:
+                self.module.fail_json(msg='Could not fetch component %s in realm %s: %s'
+                                          % (cid, realm, str(e)))
+        except Exception as e:
+            self.module.fail_json(msg='Could not fetch component %s in realm %s: %s'
+                                      % (cid, realm, str(e)))
+
+    def create_component(self, comprep, realm='master'):
+        """ Create an component.
+        :param comprep: Component representation of the component to be created.
+        :param realm: Realm in which this component resides, default "master".
+        :return: Component representation of the created component
+        """
+        comps_url = URL_COMPONENTS.format(url=self.baseurl, realm=realm)
+        try:
+            resp = open_url(comps_url, method='POST', headers=self.restheaders,
+                            data=json.dumps(comprep), validate_certs=self.validate_certs)
+            comp_url = resp.getheader('Location')
+            if comp_url is None:
+                self.module.fail_json(msg='Could not create component in realm %s: %s'
+                                          % (realm, 'unexpected response'))
+            return json.loads(to_native(open_url(comp_url, method="GET", headers=self.restheaders,
+                                                 validate_certs=self.validate_certs).read()))
+        except Exception as e:
+            self.module.fail_json(msg='Could not create component in realm %s: %s'
+                                      % (realm, str(e)))
+
+    def update_component(self, comprep, realm='master'):
+        """ Update an existing component.
+        :param comprep: Component representation of the component to be updated.
+        :param realm: Realm in which this component resides, default "master".
+        :return HTTPResponse object on success
+        """
+        cid = comprep.get('id')
+        if cid is None:
+            self.module.fail_json(msg='Cannot update component without id')
+        comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid)
+        try:
+            return open_url(comp_url, method='PUT', headers=self.restheaders,
+                            data=json.dumps(comprep), validate_certs=self.validate_certs)
+        except Exception as e:
+            self.module.fail_json(msg='Could not update component %s in realm %s: %s'
+                                      % (cid, realm, str(e)))
+
+    def delete_component(self, cid, realm='master'):
+        """ Delete an component.
+        :param cid: Unique ID of the component.
+        :param realm: Realm in which this component resides, default "master".
+        """
+        comp_url = URL_COMPONENT.format(url=self.baseurl, realm=realm, id=cid)
+        try:
+            return open_url(comp_url, method='DELETE', headers=self.restheaders,
+                            validate_certs=self.validate_certs)
+        except Exception as e:
+            self.module.fail_json(msg='Unable to delete component %s in realm %s: %s'
+                                      % (cid, realm, str(e)))
diff --git a/plugins/modules/identity/keycloak/keycloak_user_federation.py b/plugins/modules/identity/keycloak/keycloak_user_federation.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/identity/keycloak/keycloak_user_federation.py
@@ -0,0 +1,979 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+DOCUMENTATION = '''
+---
+module: keycloak_user_federation
+
+short_description: Allows administration of Keycloak user federations via Keycloak API
+
+version_added: 3.7.0
+
+description:
+    - This module allows you to add, remove or modify Keycloak user federations via the Keycloak REST API.
+      It requires access to the REST API via OpenID Connect; the user connecting and the client being
+      used must have the requisite access rights. In a default Keycloak installation, admin-cli
+      and an admin user would work, as would a separate client definition with the scope tailored
+      to your needs and a user having the expected roles.
+
+    - The names of module options are snake_cased versions of the camelCase ones found in the
+      Keycloak API and its documentation at U(https://www.keycloak.org/docs-api/15.0/rest-api/index.html).
+
+
+options:
+    state:
+        description:
+            - State of the user federation.
+            - On C(present), the user federation will be created if it does not yet exist, or updated with
+              the parameters you provide.
+            - On C(absent), the user federation will be removed if it exists.
+        default: 'present'
+        type: str
+        choices:
+            - present
+            - absent
+
+    realm:
+        description:
+            - The Keycloak realm under which this user federation resides.
+        default: 'master'
+        type: str
+
+    id:
+        description:
+            - The unique ID for this user federation. If left empty, the user federation will be searched
+              by its I(name).
+        type: str
+
+    name:
+        description:
+            - Display name of provider when linked in admin console.
+        type: str
+
+    provider_id:
+        description:
+            - Provider for this user federation.
+        aliases:
+            - providerId
+        type: str
+        choices:
+            - ldap
+            - kerberos
+
+    provider_type:
+        description:
+            - Component type for user federation (only supported value is C(org.keycloak.storage.UserStorageProvider)).
+        aliases:
+            - providerType
+        default: org.keycloak.storage.UserStorageProvider
+        type: str
+
+    parent_id:
+        description:
+            - Unique ID for the parent of this user federation. Realm ID will be automatically used if left blank.
+        aliases:
+            - parentId
+        type: str
+
+    config:
+        description:
+            - Dict specifying the configuration options for the provider; the contents differ depending on
+              the value of I(provider_id). Examples are given below for C(ldap) and C(kerberos). It is easiest
+              to obtain valid config values by dumping an already-existing user federation configuration
+              through check-mode in the I(existing) field.
+        type: dict
+        suboptions:
+            enabled:
+                description:
+                    - Enable/disable this user federation.
+                default: true
+                type: bool
+
+            priority:
+                description:
+                    - Priority of provider when doing a user lookup. Lowest first.
+                default: 0
+                type: int
+
+            importEnabled:
+                description:
+                    - If C(true), LDAP users will be imported into Keycloak DB and synced by the configured
+                      sync policies.
+                default: true
+                type: bool
+
+            editMode:
+                description:
+                    - C(READ_ONLY) is a read-only LDAP store. C(WRITABLE) means data will be synced back to LDAP
+                      on demand. C(UNSYNCED) means user data will be imported, but not synced back to LDAP.
+                type: str
+                choices:
+                    - READ_ONLY
+                    - WRITABLE
+                    - UNSYNCED
+
+            syncRegistrations:
+                description:
+                    - Should newly created users be created within LDAP store? Priority effects which
+                      provider is chosen to sync the new user.
+                default: false
+                type: bool
+
+            vendor:
+                description:
+                    - LDAP vendor (provider).
+                type: str
+
+            usernameLDAPAttribute:
+                description:
+                    - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server
+                      vendors it can be C(uid). For Active directory it can be C(sAMAccountName) or C(cn).
+                      The attribute should be filled for all LDAP user records you want to import from
+                      LDAP to Keycloak.
+                type: str
+
+            rdnLDAPAttribute:
+                description:
+                    - Name of LDAP attribute, which is used as RDN (top attribute) of typical user DN.
+                      Usually it's the same as Username LDAP attribute, however it is not required. For
+                      example for Active directory, it is common to use C(cn) as RDN attribute when
+                      username attribute might be C(sAMAccountName).
+                type: str
+
+            uuidLDAPAttribute:
+                description:
+                    - Name of LDAP attribute, which is used as unique object identifier (UUID) for objects
+                      in LDAP. For many LDAP server vendors, it is C(entryUUID); however some are different.
+                      For example for Active directory it should be C(objectGUID). If your LDAP server does
+                      not support the notion of UUID, you can use any other attribute that is supposed to
+                      be unique among LDAP users in tree.
+                type: str
+
+            userObjectClasses:
+                description:
+                    - All values of LDAP objectClass attribute for users in LDAP divided by comma.
+                      For example C(inetOrgPerson, organizationalPerson). Newly created Keycloak users
+                      will be written to LDAP with all those object classes and existing LDAP user records
+                      are found just if they contain all those object classes.
+                type: str
+
+            connectionUrl:
+                description:
+                    - Connection URL to your LDAP server.
+                type: str
+
+            usersDn:
+                description:
+                    - Full DN of LDAP tree where your users are. This DN is the parent of LDAP users.
+                type: str
+
+            customUserSearchFilter:
+                description:
+                    - Additional LDAP Filter for filtering searched users. Leave this empty if you don't
+                      need additional filter.
+                type: str
+
+            searchScope:
+                description:
+                    - For one level, the search applies only for users in the DNs specified by User DNs.
+                      For subtree, the search applies to the whole subtree. See LDAP documentation for
+                      more details
+                default: '1'
+                type: str
+                choices:
+                    - '1'
+                    - '2'
+
+            authType:
+                description:
+                    - Type of the Authentication method used during LDAP Bind operation. It is used in
+                      most of the requests sent to the LDAP server.
+                default: 'none'
+                type: str
+                choices:
+                    - none
+                    - simple
+
+            bindDn:
+                description:
+                    - DN of LDAP user which will be used by Keycloak to access LDAP server.
+                type: str
+
+            bindCredential:
+                description:
+                    - Password of LDAP admin.
+                type: str
+
+            startTls:
+                description:
+                    - Encrypts the connection to LDAP using STARTTLS, which will disable connection pooling.
+                default: false
+                type: bool
+
+            usePasswordModifyExtendedOp:
+                description:
+                    - Use the LDAPv3 Password Modify Extended Operation (RFC-3062). The password modify
+                      extended operation usually requires that LDAP user already has password in the LDAP
+                      server. So when this is used with 'Sync Registrations', it can be good to add also
+                      'Hardcoded LDAP attribute mapper' with randomly generated initial password.
+                default: false
+                type: bool
+
+            validatePasswordPolicy:
+                description:
+                    - Determines if Keycloak should validate the password with the realm password policy
+                      before updating it.
+                default: false
+                type: bool
+
+            trustEmail:
+                description:
+                    - If enabled, email provided by this provider is not verified even if verification is
+                      enabled for the realm.
+                default: false
+                type: bool
+
+            useTruststoreSpi:
+                description:
+                    - Specifies whether LDAP connection will use the truststore SPI with the truststore
+                      configured in standalone.xml/domain.xml. C(Always) means that it will always use it.
+                      C(Never) means that it will not use it. C(Only for ldaps) means that it will use if
+                      your connection URL use ldaps. Note even if standalone.xml/domain.xml is not
+                      configured, the default Java cacerts or certificate specified by
+                      C(javax.net.ssl.trustStore) property will be used.
+                default: ldapsOnly
+                type: str
+                choices:
+                    - always
+                    - ldapsOnly
+                    - never
+
+            connectionTimeout:
+                description:
+                    - LDAP Connection Timeout in milliseconds.
+                type: int
+
+            readTimeout:
+                description:
+                    - LDAP Read Timeout in milliseconds. This timeout applies for LDAP read operations.
+                type: int
+
+            pagination:
+                description:
+                    - Does the LDAP server support pagination.
+                default: true
+                type: bool
+
+            connectionPooling:
+                description:
+                    - Determines if Keycloak should use connection pooling for accessing LDAP server.
+                default: true
+                type: bool
+
+            connectionPoolingAuthentication:
+                description:
+                    - A list of space-separated authentication types of connections that may be pooled.
+                type: str
+                choices:
+                    - none
+                    - simple
+                    - DIGEST-MD5
+
+            connectionPoolingDebug:
+                description:
+                    - A string that indicates the level of debug output to produce. Example valid values are
+                      C(fine) (trace connection creation and removal) and C(all) (all debugging information).
+                type: str
+
+            connectionPoolingInitSize:
+                description:
+                    - The number of connections per connection identity to create when initially creating a
+                      connection for the identity.
+                type: int
+
+            connectionPoolingMaxSize:
+                description:
+                    - The maximum number of connections per connection identity that can be maintained
+                      concurrently.
+                type: int
+
+            connectionPoolingPrefSize:
+                description:
+                    - The preferred number of connections per connection identity that should be maintained
+                      concurrently.
+                type: int
+
+            connectionPoolingProtocol:
+                description:
+                    - A list of space-separated protocol types of connections that may be pooled.
+                      Valid types are C(plain) and C(ssl).
+                type: str
+
+            connectionPoolingTimeout:
+                description:
+                    - The number of milliseconds that an idle connection may remain in the pool without
+                      being closed and removed from the pool.
+                type: int
+
+            allowKerberosAuthentication:
+                description:
+                    - Enable/disable HTTP authentication of users with SPNEGO/Kerberos tokens. The data
+                      about authenticated users will be provisioned from this LDAP server.
+                default: false
+                type: bool
+
+            kerberosRealm:
+                description:
+                    - Name of kerberos realm.
+                type: str
+
+            serverPrincipal:
+                description:
+                    - Full name of server principal for HTTP service including server and domain name. For
+                      example C(HTTP/host.foo.org@FOO.ORG). Use C(*) to accept any service principal in the
+                      KeyTab file.
+                type: str
+
+            keyTab:
+                description:
+                    - Location of Kerberos KeyTab file containing the credentials of server principal. For
+                      example C(/etc/krb5.keytab).
+                type: str
+
+            debug:
+                description:
+                    - Enable/disable debug logging to standard output for Krb5LoginModule.
+                type: bool
+
+            useKerberosForPasswordAuthentication:
+                description:
+                    - Use Kerberos login module for authenticate username/password against Kerberos server
+                      instead of authenticating against LDAP server with Directory Service API.
+                default: false
+                type: bool
+
+            allowPasswordAuthentication:
+                description:
+                    - Enable/disable possibility of username/password authentication against Kerberos database.
+                type: bool
+
+            batchSizeForSync:
+                description:
+                    - Count of LDAP users to be imported from LDAP to Keycloak within a single transaction.
+                default: 1000
+                type: int
+
+            fullSyncPeriod:
+                description:
+                    - Period for full synchronization in seconds.
+                default: -1
+                type: int
+
+            changedSyncPeriod:
+                description:
+                    - Period for synchronization of changed or newly created LDAP users in seconds.
+                default: -1
+                type: int
+
+            updateProfileFirstLogin:
+                description:
+                    - Update profile on first login.
+                type: bool
+
+            cachePolicy:
+                description:
+                    - Cache Policy for this storage provider.
+                type: str
+                default: 'DEFAULT'
+                choices:
+                    - DEFAULT
+                    - EVICT_DAILY
+                    - EVICT_WEEKLY
+                    - MAX_LIFESPAN
+                    - NO_CACHE
+
+            evictionDay:
+                description:
+                    - Day of the week the entry will become invalid on.
+                type: str
+
+            evictionHour:
+                description:
+                    - Hour of day the entry will become invalid on.
+                type: str
+
+            evictionMinute:
+                description:
+                    - Minute of day the entry will become invalid on.
+                type: str
+
+            maxLifespan:
+                description:
+                    - Max lifespan of cache entry in milliseconds.
+                type: int
+
+    mappers:
+        description:
+            - A list of dicts defining mappers associated with this Identity Provider.
+        type: list
+        elements: dict
+        suboptions:
+            id:
+                description:
+                    - Unique ID of this mapper.
+                type: str
+
+            name:
+                description:
+                    - Name of the mapper. If no ID is given, the mapper will be searched by name.
+                type: str
+
+            parentId:
+                description:
+                    - Unique ID for the parent of this mapper. ID of the user federation will automatically
+                      be used if left blank.
+                type: str
+
+            providerId:
+                description:
+                    - The mapper type for this mapper (for instance C(user-attribute-ldap-mapper)).
+                type: str
+
+            providerType:
+                description:
+                    - Component type for this mapper (only supported value is C(org.keycloak.storage.ldap.mappers.LDAPStorageMapper)).
+                type: str
+
+            config:
+                description:
+                    - Dict specifying the configuration options for the mapper; the contents differ
+                      depending on the value of I(identityProviderMapper).
+                type: dict
+
+extends_documentation_fragment:
+- community.general.keycloak
+
+author:
+    - Laurent Paumier (@laurpaum)
+'''
+
+EXAMPLES = '''
+  - name: Create LDAP user federation
+    community.general.keycloak_user_federation:
+      auth_keycloak_url: https://keycloak.example.com/auth
+      auth_realm: master
+      auth_username: admin
+      auth_password: password
+      realm: my-realm
+      name: my-ldap
+      state: present
+      provider_id: ldap
+      provider_type: org.keycloak.storage.UserStorageProvider
+      config:
+        priority: 0
+        enabled: true
+        cachePolicy: DEFAULT
+        batchSizeForSync: 1000
+        editMode: READ_ONLY
+        importEnabled: true
+        syncRegistrations: false
+        vendor: other
+        usernameLDAPAttribute: uid
+        rdnLDAPAttribute: uid
+        uuidLDAPAttribute: entryUUID
+        userObjectClasses: inetOrgPerson, organizationalPerson
+        connectionUrl: ldaps://ldap.example.com:636
+        usersDn: ou=Users,dc=example,dc=com
+        authType: simple
+        bindDn: cn=directory reader
+        bindCredential: password
+        searchScope: 1
+        validatePasswordPolicy: false
+        trustEmail: false
+        useTruststoreSpi: ldapsOnly
+        connectionPooling: true
+        pagination: true
+        allowKerberosAuthentication: false
+        debug: false
+        useKerberosForPasswordAuthentication: false
+      mappers:
+        - name: "full name"
+          providerId: "full-name-ldap-mapper"
+          providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"
+          config:
+            ldap.full.name.attribute: cn
+            read.only: true
+            write.only: false
+
+  - name: Create Kerberos user federation
+    community.general.keycloak_user_federation:
+      auth_keycloak_url: https://keycloak.example.com/auth
+      auth_realm: master
+      auth_username: admin
+      auth_password: password
+      realm: my-realm
+      name: my-kerberos
+      state: present
+      provider_id: kerberos
+      provider_type: org.keycloak.storage.UserStorageProvider
+      config:
+        priority: 0
+        enabled: true
+        cachePolicy: DEFAULT
+        kerberosRealm: EXAMPLE.COM
+        serverPrincipal: HTTP/host.example.com@EXAMPLE.COM
+        keyTab: keytab
+        allowPasswordAuthentication: false
+        updateProfileFirstLogin: false
+
+  - name: Delete user federation
+    community.general.keycloak_user_federation:
+      auth_keycloak_url: https://keycloak.example.com/auth
+      auth_realm: master
+      auth_username: admin
+      auth_password: password
+      realm: my-realm
+      name: my-federation
+      state: absent
+
+'''
+
+RETURN = '''
+msg:
+    description: Message as to what action was taken.
+    returned: always
+    type: str
+    sample: "No changes required to user federation 164bb483-c613-482e-80fe-7f1431308799."
+
+proposed:
+    description: Representation of proposed changes to user federation.
+    returned: always
+    type: dict
+    sample: {
+        "config": {
+            "allowKerberosAuthentication": "false",
+            "authType": "simple",
+            "batchSizeForSync": "1000",
+            "bindCredential": "**********",
+            "bindDn": "cn=directory reader",
+            "cachePolicy": "DEFAULT",
+            "connectionPooling": "true",
+            "connectionUrl": "ldaps://ldap.example.com:636",
+            "debug": "false",
+            "editMode": "READ_ONLY",
+            "enabled": "true",
+            "importEnabled": "true",
+            "pagination": "true",
+            "priority": "0",
+            "rdnLDAPAttribute": "uid",
+            "searchScope": "1",
+            "syncRegistrations": "false",
+            "trustEmail": "false",
+            "useKerberosForPasswordAuthentication": "false",
+            "useTruststoreSpi": "ldapsOnly",
+            "userObjectClasses": "inetOrgPerson, organizationalPerson",
+            "usernameLDAPAttribute": "uid",
+            "usersDn": "ou=Users,dc=example,dc=com",
+            "uuidLDAPAttribute": "entryUUID",
+            "validatePasswordPolicy": "false",
+            "vendor": "other"
+        },
+        "name": "ldap",
+        "providerId": "ldap",
+        "providerType": "org.keycloak.storage.UserStorageProvider"
+    }
+
+existing:
+    description: Representation of existing user federation.
+    returned: always
+    type: dict
+    sample: {
+        "config": {
+            "allowKerberosAuthentication": "false",
+            "authType": "simple",
+            "batchSizeForSync": "1000",
+            "bindCredential": "**********",
+            "bindDn": "cn=directory reader",
+            "cachePolicy": "DEFAULT",
+            "changedSyncPeriod": "-1",
+            "connectionPooling": "true",
+            "connectionUrl": "ldaps://ldap.example.com:636",
+            "debug": "false",
+            "editMode": "READ_ONLY",
+            "enabled": "true",
+            "fullSyncPeriod": "-1",
+            "importEnabled": "true",
+            "pagination": "true",
+            "priority": "0",
+            "rdnLDAPAttribute": "uid",
+            "searchScope": "1",
+            "syncRegistrations": "false",
+            "trustEmail": "false",
+            "useKerberosForPasswordAuthentication": "false",
+            "useTruststoreSpi": "ldapsOnly",
+            "userObjectClasses": "inetOrgPerson, organizationalPerson",
+            "usernameLDAPAttribute": "uid",
+            "usersDn": "ou=Users,dc=example,dc=com",
+            "uuidLDAPAttribute": "entryUUID",
+            "validatePasswordPolicy": "false",
+            "vendor": "other"
+        },
+        "id": "01122837-9047-4ae4-8ca0-6e2e891a765f",
+        "mappers": [
+            {
+                "config": {
+                    "always.read.value.from.ldap": "false",
+                    "is.mandatory.in.ldap": "false",
+                    "ldap.attribute": "mail",
+                    "read.only": "true",
+                    "user.model.attribute": "email"
+                },
+                "id": "17d60ce2-2d44-4c2c-8b1f-1fba601b9a9f",
+                "name": "email",
+                "parentId": "01122837-9047-4ae4-8ca0-6e2e891a765f",
+                "providerId": "user-attribute-ldap-mapper",
+                "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"
+            }
+        ],
+        "name": "myfed",
+        "parentId": "myrealm",
+        "providerId": "ldap",
+        "providerType": "org.keycloak.storage.UserStorageProvider"
+    }
+
+end_state:
+    description: Representation of user federation after module execution.
+    returned: always
+    type: dict
+    sample: {
+        "config": {
+            "allowPasswordAuthentication": "false",
+            "cachePolicy": "DEFAULT",
+            "enabled": "true",
+            "kerberosRealm": "EXAMPLE.COM",
+            "keyTab": "/etc/krb5.keytab",
+            "priority": "0",
+            "serverPrincipal": "HTTP/host.example.com@EXAMPLE.COM",
+            "updateProfileFirstLogin": "false"
+        },
+        "id": "cf52ae4f-4471-4435-a0cf-bb620cadc122",
+        "mappers": [],
+        "name": "kerberos",
+        "parentId": "myrealm",
+        "providerId": "kerberos",
+        "providerType": "org.keycloak.storage.UserStorageProvider"
+    }
+
+'''
+
+from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
+    keycloak_argument_spec, get_token, KeycloakError
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.six.moves.urllib.parse import urlencode
+from copy import deepcopy
+
+
+def sanitize(comp):
+    compcopy = deepcopy(comp)
+    if 'config' in compcopy:
+        compcopy['config'] = dict((k, v[0]) for k, v in compcopy['config'].items())
+        if 'bindCredential' in compcopy['config']:
+            compcopy['config']['bindCredential'] = '**********'
+    if 'mappers' in compcopy:
+        for mapper in compcopy['mappers']:
+            if 'config' in mapper:
+                mapper['config'] = dict((k, v[0]) for k, v in mapper['config'].items())
+    return compcopy
+
+
+def main():
+    """
+    Module execution
+
+    :return:
+    """
+    argument_spec = keycloak_argument_spec()
+
+    config_spec = dict(
+        allowKerberosAuthentication=dict(type='bool', default=False),
+        allowPasswordAuthentication=dict(type='bool'),
+        authType=dict(type='str', choices=['none', 'simple'], default='none'),
+        batchSizeForSync=dict(type='int', default=1000),
+        bindCredential=dict(type='str', no_log=True),
+        bindDn=dict(type='str'),
+        cachePolicy=dict(type='str', choices=['DEFAULT', 'EVICT_DAILY', 'EVICT_WEEKLY', 'MAX_LIFESPAN', 'NO_CACHE'], default='DEFAULT'),
+        changedSyncPeriod=dict(type='int', default=-1),
+        connectionPooling=dict(type='bool', default=True),
+        connectionPoolingAuthentication=dict(type='str', choices=['none', 'simple', 'DIGEST-MD5']),
+        connectionPoolingDebug=dict(type='str'),
+        connectionPoolingInitSize=dict(type='int'),
+        connectionPoolingMaxSize=dict(type='int'),
+        connectionPoolingPrefSize=dict(type='int'),
+        connectionPoolingProtocol=dict(type='str'),
+        connectionPoolingTimeout=dict(type='int'),
+        connectionTimeout=dict(type='int'),
+        connectionUrl=dict(type='str'),
+        customUserSearchFilter=dict(type='str'),
+        debug=dict(type='bool'),
+        editMode=dict(type='str', choices=['READ_ONLY', 'WRITABLE', 'UNSYNCED']),
+        enabled=dict(type='bool', default=True),
+        evictionDay=dict(type='str'),
+        evictionHour=dict(type='str'),
+        evictionMinute=dict(type='str'),
+        fullSyncPeriod=dict(type='int', default=-1),
+        importEnabled=dict(type='bool', default=True),
+        kerberosRealm=dict(type='str'),
+        keyTab=dict(type='str', no_log=False),
+        maxLifespan=dict(type='int'),
+        pagination=dict(type='bool', default=True),
+        priority=dict(type='int', default=0),
+        rdnLDAPAttribute=dict(type='str'),
+        readTimeout=dict(type='int'),
+        searchScope=dict(type='str', choices=['1', '2'], default='1'),
+        serverPrincipal=dict(type='str'),
+        startTls=dict(type='bool', default=False),
+        syncRegistrations=dict(type='bool', default=False),
+        trustEmail=dict(type='bool', default=False),
+        updateProfileFirstLogin=dict(type='bool'),
+        useKerberosForPasswordAuthentication=dict(type='bool', default=False),
+        usePasswordModifyExtendedOp=dict(type='bool', default=False, no_log=False),
+        useTruststoreSpi=dict(type='str', choices=['always', 'ldapsOnly', 'never'], default='ldapsOnly'),
+        userObjectClasses=dict(type='str'),
+        usernameLDAPAttribute=dict(type='str'),
+        usersDn=dict(type='str'),
+        uuidLDAPAttribute=dict(type='str'),
+        validatePasswordPolicy=dict(type='bool', default=False),
+        vendor=dict(type='str'),
+    )
+
+    mapper_spec = dict(
+        id=dict(type='str'),
+        name=dict(type='str'),
+        parentId=dict(type='str'),
+        providerId=dict(type='str'),
+        providerType=dict(type='str'),
+        config=dict(type='dict'),
+    )
+
+    meta_args = dict(
+        config=dict(type='dict', options=config_spec),
+        state=dict(type='str', default='present', choices=['present', 'absent']),
+        realm=dict(type='str', default='master'),
+        id=dict(type='str'),
+        name=dict(type='str'),
+        provider_id=dict(type='str', aliases=['providerId'], choices=['ldap', 'kerberos']),
+        provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'),
+        parent_id=dict(type='str', aliases=['parentId']),
+        mappers=dict(type='list', elements='dict', options=mapper_spec),
+    )
+
+    argument_spec.update(meta_args)
+
+    module = AnsibleModule(argument_spec=argument_spec,
+                           supports_check_mode=True,
+                           required_one_of=([['id', 'name'],
+                                             ['token', 'auth_realm', 'auth_username', 'auth_password']]),
+                           required_together=([['auth_realm', 'auth_username', 'auth_password']]))
+
+    result = dict(changed=False, msg='', diff={}, proposed={}, existing={}, end_state={})
+
+    # Obtain access token, initialize API
+    try:
+        connection_header = get_token(module.params)
+    except KeycloakError as e:
+        module.fail_json(msg=str(e))
+
+    kc = KeycloakAPI(module, connection_header)
+
+    realm = module.params.get('realm')
+    state = module.params.get('state')
+    config = module.params.get('config')
+    mappers = module.params.get('mappers')
+    cid = module.params.get('id')
+    name = module.params.get('name')
+
+    # Keycloak API expects config parameters to be arrays containing a single string element
+    if config is not None:
+        module.params['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v])
+                                       for k, v in config.items() if config[k] is not None)
+
+    if mappers is not None:
+        for mapper in mappers:
+            if mapper.get('config') is not None:
+                mapper['config'] = dict((k, [str(v).lower() if not isinstance(v, str) else v])
+                                        for k, v in mapper['config'].items() if mapper['config'][k] is not None)
+
+    # convert module parameters to client representation parameters (if they belong in there)
+    comp_params = [x for x in module.params
+                   if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers'] and
+                   module.params.get(x) is not None]
+
+    # does the user federation already exist?
+    if cid is None:
+        found = kc.get_components(urlencode(dict(type='org.keycloak.storage.UserStorageProvider', parent=realm, name=name)), realm)
+        if len(found) > 1:
+            module.fail_json(msg='No ID given and found multiple user federations with name `{name}`. Cannot continue.'.format(name=name))
+        before_comp = next(iter(found), None)
+        if before_comp is not None:
+            cid = before_comp['id']
+    else:
+        before_comp = kc.get_component(cid, realm)
+
+    if before_comp is None:
+        before_comp = dict()
+
+    # if user federation exists, get associated mappers
+    if cid is not None:
+        before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name'))
+
+    # build a changeset
+    changeset = dict()
+
+    for param in comp_params:
+        new_param_value = module.params.get(param)
+        old_value = before_comp[camel(param)] if camel(param) in before_comp else None
+        if param == 'mappers':
+            new_param_value = [dict((k, v) for k, v in x.items() if x[k] is not None) for x in new_param_value]
+        if new_param_value != old_value:
+            changeset[camel(param)] = new_param_value
+
+    # special handling of mappers list to allow change detection
+    if module.params.get('mappers') is not None:
+        if module.params['provider_id'] == 'kerberos':
+            module.fail_json(msg='Cannot configure mappers for Kerberos federations.')
+        for change in module.params['mappers']:
+            change = dict((k, v) for k, v in change.items() if change[k] is not None)
+            if change.get('id') is None and change.get('name') is None:
+                module.fail_json(msg='Either `name` or `id` has to be specified on each mapper.')
+            if cid is None:
+                old_mapper = dict()
+            elif change.get('id') is not None:
+                old_mapper = kc.get_component(change['id'], realm)
+                if old_mapper is None:
+                    old_mapper = dict()
+            else:
+                found = kc.get_components(urlencode(dict(parent=cid, name=change['name'])), realm)
+                if len(found) > 1:
+                    module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=change['name']))
+                if len(found) == 1:
+                    old_mapper = found[0]
+                else:
+                    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)
+
+    # prepare the new representation
+    updated_comp = before_comp.copy()
+    updated_comp.update(changeset)
+
+    result['proposed'] = sanitize(changeset)
+    result['existing'] = sanitize(before_comp)
+
+    # if before_comp is none, the user federation doesn't exist.
+    if before_comp == dict():
+        if state == 'absent':
+            # nothing to do.
+            if module._diff:
+                result['diff'] = dict(before='', after='')
+            result['changed'] = False
+            result['end_state'] = dict()
+            result['msg'] = 'User federation does not exist; doing nothing.'
+            module.exit_json(**result)
+
+        # for 'present', create a new user federation.
+        result['changed'] = True
+
+        if module._diff:
+            result['diff'] = dict(before='', after=sanitize(updated_comp))
+
+        if module.check_mode:
+            module.exit_json(**result)
+
+        # do it for real!
+        updated_comp = updated_comp.copy()
+        updated_mappers = updated_comp.pop('mappers', [])
+        after_comp = kc.create_component(updated_comp, realm)
+
+        for mapper in updated_mappers:
+            if mapper.get('id') is not None:
+                kc.update_component(mapper, realm)
+            else:
+                if mapper.get('parentId') is None:
+                    mapper['parentId'] = after_comp['id']
+                mapper = kc.create_component(mapper, realm)
+
+        after_comp['mappers'] = updated_mappers
+        result['end_state'] = sanitize(after_comp)
+
+        result['msg'] = "User federation {id} has been created".format(id=after_comp['id'])
+        module.exit_json(**result)
+
+    else:
+        if state == 'present':
+            # no changes
+            if updated_comp == before_comp:
+                result['changed'] = False
+                result['end_state'] = sanitize(updated_comp)
+                result['msg'] = "No changes required to user federation {id}.".format(id=cid)
+                module.exit_json(**result)
+
+            # update the existing role
+            result['changed'] = True
+
+            if module._diff:
+                result['diff'] = dict(before=sanitize(before_comp), after=sanitize(updated_comp))
+
+            if module.check_mode:
+                module.exit_json(**result)
+
+            # do the update
+            updated_comp = updated_comp.copy()
+            updated_mappers = updated_comp.pop('mappers', [])
+            kc.update_component(updated_comp, realm)
+            after_comp = kc.get_component(cid, realm)
+
+            for mapper in updated_mappers:
+                if mapper.get('id') is not None:
+                    kc.update_component(mapper, realm)
+                else:
+                    if mapper.get('parentId') is None:
+                        mapper['parentId'] = updated_comp['id']
+                    mapper = kc.create_component(mapper, realm)
+
+            after_comp['mappers'] = updated_mappers
+            result['end_state'] = sanitize(after_comp)
+
+            result['msg'] = "User federation {id} has been updated".format(id=cid)
+            module.exit_json(**result)
+
+        elif state == 'absent':
+            result['changed'] = True
+
+            if module._diff:
+                result['diff'] = dict(before=sanitize(before_comp), after='')
+
+            if module.check_mode:
+                module.exit_json(**result)
+
+            # delete for real
+            kc.delete_component(cid, realm)
+
+            result['end_state'] = dict()
+
+            result['msg'] = "User federation {id} has been deleted".format(id=cid)
+            module.exit_json(**result)
+
+    module.exit_json(**result)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/plugins/modules/keycloak_user_federation.py b/plugins/modules/keycloak_user_federation.py
new file mode 120000
--- /dev/null
+++ b/plugins/modules/keycloak_user_federation.py
@@ -0,0 +1 @@
+identity/keycloak/keycloak_user_federation.py
\ No newline at end of file


Test patch:
diff --git a/tests/integration/targets/keycloak_user_federation/aliases b/tests/integration/targets/keycloak_user_federation/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_user_federation/aliases
@@ -0,0 +1 @@
+unsupported
diff --git a/tests/integration/targets/keycloak_user_federation/tasks/main.yml b/tests/integration/targets/keycloak_user_federation/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_user_federation/tasks/main.yml
@@ -0,0 +1,225 @@
+---
+- name: Create realm
+  community.general.keycloak_realm:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    id: "{{ realm }}"
+    realm: "{{ realm }}"
+    state: present
+
+- name: Create new user federation
+  community.general.keycloak_user_federation:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    name: "{{ federation }}"
+    state: present
+    provider_id: ldap
+    provider_type: org.keycloak.storage.UserStorageProvider
+    config:
+      enabled: true
+      priority: 0
+      fullSyncPeriod: -1
+      changedSyncPeriod: -1
+      cachePolicy: DEFAULT
+      batchSizeForSync: 1000
+      editMode: READ_ONLY
+      importEnabled: true
+      syncRegistrations: false
+      vendor: other
+      usernameLDAPAttribute: uid
+      rdnLDAPAttribute: uid
+      uuidLDAPAttribute: entryUUID
+      userObjectClasses: "inetOrgPerson, organizationalPerson"
+      connectionUrl: "ldaps://ldap.example.com:636"
+      usersDn: "ou=Users,dc=example,dc=com"
+      authType: simple
+      bindDn: cn=directory reader
+      bindCredential: secret
+      searchScope: 1
+      validatePasswordPolicy: false
+      trustEmail: false
+      useTruststoreSpi: "ldapsOnly"
+      connectionPooling: true
+      pagination: true
+      allowKerberosAuthentication: false
+      useKerberosForPasswordAuthentication: false
+      debug: false
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert user federation created
+  assert:
+    that:
+      - result is changed
+      - result.existing == {}
+      - result.end_state.name == "{{ federation }}"
+
+- name: Update existing user federation (no change)
+  community.general.keycloak_user_federation:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    name: "{{ federation }}"
+    state: present
+    provider_id: ldap
+    provider_type: org.keycloak.storage.UserStorageProvider
+    config:
+      enabled: true
+      priority: 0
+      fullSyncPeriod: -1
+      changedSyncPeriod: -1
+      cachePolicy: DEFAULT
+      batchSizeForSync: 1000
+      editMode: READ_ONLY
+      importEnabled: true
+      syncRegistrations: false
+      vendor: other
+      usernameLDAPAttribute: uid
+      rdnLDAPAttribute: uid
+      uuidLDAPAttribute: entryUUID
+      userObjectClasses: "inetOrgPerson, organizationalPerson"
+      connectionUrl: "ldaps://ldap.example.com:636"
+      usersDn: "ou=Users,dc=example,dc=com"
+      authType: simple
+      bindDn: cn=directory reader
+      bindCredential: "**********"
+      searchScope: 1
+      validatePasswordPolicy: false
+      trustEmail: false
+      useTruststoreSpi: "ldapsOnly"
+      connectionPooling: true
+      pagination: true
+      allowKerberosAuthentication: false
+      useKerberosForPasswordAuthentication: false
+      debug: false
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert user federation unchanged
+  assert:
+    that:
+      - result is not changed
+      - result.existing != {}
+      - result.existing.name == "{{ federation }}"
+      - result.end_state != {}
+      - result.end_state.name == "{{ federation }}"
+
+- name: Update existing user federation (with change)
+  community.general.keycloak_user_federation:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    name: "{{ federation }}"
+    state: present
+    provider_id: ldap
+    provider_type: org.keycloak.storage.UserStorageProvider
+    config:
+      enabled: true
+      priority: 0
+      fullSyncPeriod: -1
+      changedSyncPeriod: -1
+      cachePolicy: DEFAULT
+      batchSizeForSync: 1000
+      editMode: READ_ONLY
+      importEnabled: true
+      syncRegistrations: false
+      vendor: other
+      usernameLDAPAttribute: uid
+      rdnLDAPAttribute: uid
+      uuidLDAPAttribute: entryUUID
+      userObjectClasses: "inetOrgPerson, organizationalPerson"
+      connectionUrl: "ldaps://ldap.example.com:636"
+      usersDn: "ou=Users,dc=example,dc=com"
+      authType: simple
+      bindDn: cn=directory reader
+      bindCredential: "**********"
+      searchScope: 1
+      validatePasswordPolicy: false
+      trustEmail: false
+      useTruststoreSpi: "ldapsOnly"
+      connectionPooling: true
+      pagination: true
+      allowKerberosAuthentication: false
+      useKerberosForPasswordAuthentication: false
+      debug: false
+    mappers:
+      - name: "full name"
+        providerId: "full-name-ldap-mapper"
+        providerType: "org.keycloak.storage.ldap.mappers.LDAPStorageMapper"
+        config:
+          ldap.full.name.attribute: cn
+          read.only: true
+          write.only: false
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert user federation created
+  assert:
+    that:
+      - result is changed
+      - result.existing != {}
+      - result.existing.name == "{{ federation }}"
+      - result.end_state != {}
+      - result.end_state.name == "{{ federation }}"
+
+- name: Delete existing user federation
+  community.general.keycloak_user_federation:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    name: "{{ federation }}"
+    state: absent
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert user federation deleted
+  assert:
+    that:
+      - result is changed
+      - result.existing != {}
+      - result.end_state == {}
+
+- name: Delete absent user federation
+  community.general.keycloak_user_federation:
+    auth_keycloak_url: "{{ url }}"
+    auth_realm: "{{ admin_realm }}"
+    auth_username: "{{ admin_user }}"
+    auth_password: "{{ admin_password }}"
+    realm: "{{ realm }}"
+    name: "{{ federation }}"
+    state: absent
+  register: result
+
+- name: Debug
+  debug:
+    var: result
+
+- name: Assert user federation unchanged
+  assert:
+    that:
+      - result is not changed
+      - result.existing == {}
+      - result.end_state == {}
diff --git a/tests/integration/targets/keycloak_user_federation/vars/main.yml b/tests/integration/targets/keycloak_user_federation/vars/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/keycloak_user_federation/vars/main.yml
@@ -0,0 +1,7 @@
+---
+url: http://localhost:8080/auth
+admin_realm: master
+admin_user: admin
+admin_password: password
+realm: myrealm
+federation: myfed
diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_user_federation.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_user_federation.py
new file mode 100644
--- /dev/null
+++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_user_federation.py
@@ -0,0 +1,581 @@
+# -*- coding: utf-8 -*-
+
+# Copyright: (c) 2021, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import absolute_import, division, print_function
+__metaclass__ = type
+
+from contextlib import contextmanager
+
+from ansible_collections.community.general.tests.unit.compat import unittest
+from ansible_collections.community.general.tests.unit.compat.mock import call, patch
+from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args
+
+from ansible_collections.community.general.plugins.modules.identity.keycloak import keycloak_user_federation
+
+from itertools import count
+
+from ansible.module_utils.six import StringIO
+
+
+@contextmanager
+def patch_keycloak_api(get_components=None, get_component=None, create_component=None, update_component=None, delete_component=None):
+    """Mock context manager for patching the methods in KeycloakAPI
+    """
+
+    obj = keycloak_user_federation.KeycloakAPI
+    with patch.object(obj, 'get_components', side_effect=get_components) \
+            as mock_get_components:
+        with patch.object(obj, 'get_component', side_effect=get_component) \
+                as mock_get_component:
+            with patch.object(obj, 'create_component', side_effect=create_component) \
+                    as mock_create_component:
+                with patch.object(obj, 'update_component', side_effect=update_component) \
+                        as mock_update_component:
+                    with patch.object(obj, 'delete_component', side_effect=delete_component) \
+                            as mock_delete_component:
+                        yield mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component
+
+
+def get_response(object_with_future_response, method, get_id_call_count):
+    if callable(object_with_future_response):
+        return object_with_future_response()
+    if isinstance(object_with_future_response, dict):
+        return get_response(
+            object_with_future_response[method], method, get_id_call_count)
+    if isinstance(object_with_future_response, list):
+        call_number = next(get_id_call_count)
+        return get_response(
+            object_with_future_response[call_number], method, get_id_call_count)
+    return object_with_future_response
+
+
+def build_mocked_request(get_id_user_count, response_dict):
+    def _mocked_requests(*args, **kwargs):
+        url = args[0]
+        method = kwargs['method']
+        future_response = response_dict.get(url, None)
+        return get_response(future_response, method, get_id_user_count)
+    return _mocked_requests
+
+
+def create_wrapper(text_as_string):
+    """Allow to mock many times a call to one address.
+    Without this function, the StringIO is empty for the second call.
+    """
+    def _create_wrapper():
+        return StringIO(text_as_string)
+    return _create_wrapper
+
+
+def mock_good_connection():
+    token_response = {
+        'http://keycloak.url/auth/realms/master/protocol/openid-connect/token': create_wrapper('{"access_token": "alongtoken"}'), }
+    return patch(
+        'ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak.open_url',
+        side_effect=build_mocked_request(count(), token_response),
+        autospec=True
+    )
+
+
+class TestKeycloakUserFederation(ModuleTestCase):
+    def setUp(self):
+        super(TestKeycloakUserFederation, self).setUp()
+        self.module = keycloak_user_federation
+
+    def test_create_when_absent(self):
+        """Add a new user federation"""
+
+        module_args = {
+            'auth_keycloak_url': 'http://keycloak.url/auth',
+            'auth_realm': 'master',
+            'auth_username': 'admin',
+            'auth_password': 'admin',
+            'realm': 'realm-name',
+            'name': 'kerberos',
+            'state': 'present',
+            'provider_id': 'kerberos',
+            'provider_type': 'org.keycloak.storage.UserStorageProvider',
+            'config': {
+                'priority': 0,
+                'enabled': True,
+                'cachePolicy': 'DEFAULT',
+                'kerberosRealm': 'REALM',
+                'serverPrincipal': 'princ',
+                'keyTab': 'keytab',
+                'allowPasswordAuthentication': False,
+                'updateProfileFirstLogin': False,
+            },
+        }
+        return_value_component_create = [
+            {
+                "id": "ebb7d999-60cc-4dfe-ab79-48f7bbd9d4d9",
+                "name": "kerberos",
+                "providerId": "kerberos",
+                "providerType": "org.keycloak.storage.UserStorageProvider",
+                "parentId": "kerberos",
+                "config": {
+                    "serverPrincipal": [
+                        "princ"
+                    ],
+                    "allowPasswordAuthentication": [
+                        "false"
+                    ],
+                    "keyTab": [
+                        "keytab"
+                    ],
+                    "cachePolicy": [
+                        "DEFAULT"
+                    ],
+                    "updateProfileFirstLogin": [
+                        "false"
+                    ],
+                    "kerberosRealm": [
+                        "REALM"
+                    ],
+                    "priority": [
+                        "0"
+                    ],
+                    "enabled": [
+                        "true"
+                    ]
+                }
+            }
+        ]
+        return_value_components_get = [
+            [], []
+        ]
+        changed = True
+
+        set_module_args(module_args)
+
+        # Run the module
+
+        with mock_good_connection():
+            with patch_keycloak_api(get_components=return_value_components_get, create_component=return_value_component_create) \
+                    as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component):
+                with self.assertRaises(AnsibleExitJson) as exec_info:
+                    self.module.main()
+
+        self.assertEqual(len(mock_get_components.mock_calls), 1)
+        self.assertEqual(len(mock_get_component.mock_calls), 0)
+        self.assertEqual(len(mock_create_component.mock_calls), 1)
+        self.assertEqual(len(mock_update_component.mock_calls), 0)
+        self.assertEqual(len(mock_delete_component.mock_calls), 0)
+
+        # Verify that the module's changed status matches what is expected
+        self.assertIs(exec_info.exception.args[0]['changed'], changed)
+
+    def test_create_when_present(self):
+        """Update existing user federation"""
+
+        module_args = {
+            'auth_keycloak_url': 'http://keycloak.url/auth',
+            'auth_realm': 'master',
+            'auth_username': 'admin',
+            'auth_password': 'admin',
+            'realm': 'realm-name',
+            'name': 'kerberos',
+            'state': 'present',
+            'provider_id': 'kerberos',
+            'provider_type': 'org.keycloak.storage.UserStorageProvider',
+            'config': {
+                'priority': 0,
+                'enabled': True,
+                'cachePolicy': 'DEFAULT',
+                'kerberosRealm': 'REALM',
+                'serverPrincipal': 'princ',
+                'keyTab': 'keytab',
+                'allowPasswordAuthentication': False,
+                'updateProfileFirstLogin': False,
+            },
+        }
+        return_value_components_get = [
+            [
+                {
+                    "id": "ebb7d999-60cc-4dfe-ab79-48f7bbd9d4d9",
+                    "name": "kerberos",
+                    "providerId": "kerberos",
+                    "providerType": "org.keycloak.storage.UserStorageProvider",
+                    "parentId": "kerberos",
+                    "config": {
+                        "serverPrincipal": [
+                            "princ"
+                        ],
+                        "allowPasswordAuthentication": [
+                            "false"
+                        ],
+                        "keyTab": [
+                            "keytab"
+                        ],
+                        "cachePolicy": [
+                            "DEFAULT"
+                        ],
+                        "updateProfileFirstLogin": [
+                            "false"
+                        ],
+                        "kerberosRealm": [
+                            "REALM"
+                        ],
+                        "priority": [
+                            "0"
+                        ],
+                        "enabled": [
+                            "false"
+                        ]
+                    }
+                }
+            ],
+            []
+        ]
+        return_value_component_get = [
+            {
+                "id": "ebb7d999-60cc-4dfe-ab79-48f7bbd9d4d9",
+                "name": "kerberos",
+                "providerId": "kerberos",
+                "providerType": "org.keycloak.storage.UserStorageProvider",
+                "parentId": "kerberos",
+                "config": {
+                    "serverPrincipal": [
+                        "princ"
+                    ],
+                    "allowPasswordAuthentication": [
+                        "false"
+                    ],
+                    "keyTab": [
+                        "keytab"
+                    ],
+                    "cachePolicy": [
+                        "DEFAULT"
+                    ],
+                    "updateProfileFirstLogin": [
+                        "false"
+                    ],
+                    "kerberosRealm": [
+                        "REALM"
+                    ],
+                    "priority": [
+                        "0"
+                    ],
+                    "enabled": [
+                        "true"
+                    ]
+                }
+            }
+        ]
+        return_value_component_update = [
+            None
+        ]
+        changed = True
+
+        set_module_args(module_args)
+
+        # Run the module
+
+        with mock_good_connection():
+            with patch_keycloak_api(get_components=return_value_components_get, get_component=return_value_component_get,
+                                    update_component=return_value_component_update) \
+                    as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component):
+                with self.assertRaises(AnsibleExitJson) as exec_info:
+                    self.module.main()
+
+        self.assertEqual(len(mock_get_components.mock_calls), 2)
+        self.assertEqual(len(mock_get_component.mock_calls), 1)
+        self.assertEqual(len(mock_create_component.mock_calls), 0)
+        self.assertEqual(len(mock_update_component.mock_calls), 1)
+        self.assertEqual(len(mock_delete_component.mock_calls), 0)
+
+        # Verify that the module's changed status matches what is expected
+        self.assertIs(exec_info.exception.args[0]['changed'], changed)
+
+    def test_create_with_mappers(self):
+        """Add a new user federation with mappers"""
+
+        module_args = {
+            'auth_keycloak_url': 'http://keycloak.url/auth',
+            'auth_realm': 'master',
+            'auth_username': 'admin',
+            'auth_password': 'admin',
+            'realm': 'realm-name',
+            'name': 'ldap',
+            'state': 'present',
+            'provider_id': 'ldap',
+            'provider_type': 'org.keycloak.storage.UserStorageProvider',
+            'config': {
+                'priority': 0,
+                'enabled': True,
+                'cachePolicy': 'DEFAULT',
+                'batchSizeForSync': 1000,
+                'editMode': 'READ_ONLY',
+                'importEnabled': True,
+                'syncRegistrations': False,
+                'vendor': 'other',
+                'usernameLDAPAttribute': 'uid',
+                'rdnLDAPAttribute': 'uid',
+                'uuidLDAPAttribute': 'entryUUID',
+                'userObjectClasses': 'inetOrgPerson, organizationalPerson',
+                'connectionUrl': 'ldaps://ldap.example.com:636',
+                'usersDn': 'ou=Users,dc=example,dc=com',
+                'authType': 'none',
+                'searchScope': 1,
+                'validatePasswordPolicy': False,
+                'trustEmail': False,
+                'useTruststoreSpi': 'ldapsOnly',
+                'connectionPooling': True,
+                'pagination': True,
+                'allowKerberosAuthentication': False,
+                'debug': False,
+                'useKerberosForPasswordAuthentication': False,
+            },
+            'mappers': [
+                {
+                    'name': 'full name',
+                    'providerId': 'full-name-ldap-mapper',
+                    'providerType': 'org.keycloak.storage.ldap.mappers.LDAPStorageMapper',
+                    'config': {
+                        'ldap.full.name.attribute': 'cn',
+                        'read.only': True,
+                        'write.only': False,
+                    }
+                }
+            ]
+        }
+        return_value_components_get = [
+            []
+        ]
+        return_value_component_create = [
+            {
+                "id": "eb691537-b73c-4cd8-b481-6031c26499d8",
+                "name": "ldap",
+                "providerId": "ldap",
+                "providerType": "org.keycloak.storage.UserStorageProvider",
+                "parentId": "ldap",
+                "config": {
+                    "pagination": [
+                        "true"
+                    ],
+                    "connectionPooling": [
+                        "true"
+                    ],
+                    "usersDn": [
+                        "ou=Users,dc=example,dc=com"
+                    ],
+                    "cachePolicy": [
+                        "DEFAULT"
+                    ],
+                    "useKerberosForPasswordAuthentication": [
+                        "false"
+                    ],
+                    "importEnabled": [
+                        "true"
+                    ],
+                    "enabled": [
+                        "true"
+                    ],
+                    "usernameLDAPAttribute": [
+                        "uid"
+                    ],
+                    "vendor": [
+                        "other"
+                    ],
+                    "uuidLDAPAttribute": [
+                        "entryUUID"
+                    ],
+                    "connectionUrl": [
+                        "ldaps://ldap.example.com:636"
+                    ],
+                    "allowKerberosAuthentication": [
+                        "false"
+                    ],
+                    "syncRegistrations": [
+                        "false"
+                    ],
+                    "authType": [
+                        "none"
+                    ],
+                    "debug": [
+                        "false"
+                    ],
+                    "searchScope": [
+                        "1"
+                    ],
+                    "useTruststoreSpi": [
+                        "ldapsOnly"
+                    ],
+                    "trustEmail": [
+                        "false"
+                    ],
+                    "priority": [
+                        "0"
+                    ],
+                    "userObjectClasses": [
+                        "inetOrgPerson, organizationalPerson"
+                    ],
+                    "rdnLDAPAttribute": [
+                        "uid"
+                    ],
+                    "editMode": [
+                        "READ_ONLY"
+                    ],
+                    "validatePasswordPolicy": [
+                        "false"
+                    ],
+                    "batchSizeForSync": [
+                        "1000"
+                    ]
+                }
+            },
+            {
+                "id": "2dfadafd-8b34-495f-a98b-153e71a22311",
+                "name": "full name",
+                "providerId": "full-name-ldap-mapper",
+                "providerType": "org.keycloak.storage.ldap.mappers.LDAPStorageMapper",
+                "parentId": "eb691537-b73c-4cd8-b481-6031c26499d8",
+                "config": {
+                    "ldap.full.name.attribute": [
+                        "cn"
+                    ],
+                    "read.only": [
+                        "true"
+                    ],
+                    "write.only": [
+                        "false"
+                    ]
+                }
+            }
+        ]
+        changed = True
+
+        set_module_args(module_args)
+
+        # Run the module
+
+        with mock_good_connection():
+            with patch_keycloak_api(get_components=return_value_components_get, create_component=return_value_component_create) \
+                    as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component):
+                with self.assertRaises(AnsibleExitJson) as exec_info:
+                    self.module.main()
+
+        self.assertEqual(len(mock_get_components.mock_calls), 1)
+        self.assertEqual(len(mock_get_component.mock_calls), 0)
+        self.assertEqual(len(mock_create_component.mock_calls), 2)
+        self.assertEqual(len(mock_update_component.mock_calls), 0)
+        self.assertEqual(len(mock_delete_component.mock_calls), 0)
+
+        # Verify that the module's changed status matches what is expected
+        self.assertIs(exec_info.exception.args[0]['changed'], changed)
+
+    def test_delete_when_absent(self):
+        """Remove an absent user federation"""
+
+        module_args = {
+            'auth_keycloak_url': 'http://keycloak.url/auth',
+            'auth_realm': 'master',
+            'auth_username': 'admin',
+            'auth_password': 'admin',
+            'realm': 'realm-name',
+            'name': 'kerberos',
+            'state': 'absent',
+        }
+        return_value_components_get = [
+            []
+        ]
+        changed = False
+
+        set_module_args(module_args)
+
+        # Run the module
+
+        with mock_good_connection():
+            with patch_keycloak_api(get_components=return_value_components_get) \
+                    as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component):
+                with self.assertRaises(AnsibleExitJson) as exec_info:
+                    self.module.main()
+
+        self.assertEqual(len(mock_get_components.mock_calls), 1)
+        self.assertEqual(len(mock_get_component.mock_calls), 0)
+        self.assertEqual(len(mock_create_component.mock_calls), 0)
+        self.assertEqual(len(mock_update_component.mock_calls), 0)
+        self.assertEqual(len(mock_delete_component.mock_calls), 0)
+
+        # Verify that the module's changed status matches what is expected
+        self.assertIs(exec_info.exception.args[0]['changed'], changed)
+
+    def test_delete_when_present(self):
+        """Remove an existing user federation"""
+
+        module_args = {
+            'auth_keycloak_url': 'http://keycloak.url/auth',
+            'auth_realm': 'master',
+            'auth_username': 'admin',
+            'auth_password': 'admin',
+            'realm': 'realm-name',
+            'name': 'kerberos',
+            'state': 'absent',
+        }
+        return_value_components_get = [
+            [
+                {
+                    "id": "ebb7d999-60cc-4dfe-ab79-48f7bbd9d4d9",
+                    "name": "kerberos",
+                    "providerId": "kerberos",
+                    "providerType": "org.keycloak.storage.UserStorageProvider",
+                    "parentId": "kerberos",
+                    "config": {
+                        "serverPrincipal": [
+                            "princ"
+                        ],
+                        "allowPasswordAuthentication": [
+                            "false"
+                        ],
+                        "keyTab": [
+                            "keytab"
+                        ],
+                        "cachePolicy": [
+                            "DEFAULT"
+                        ],
+                        "updateProfileFirstLogin": [
+                            "false"
+                        ],
+                        "kerberosRealm": [
+                            "REALM"
+                        ],
+                        "priority": [
+                            "0"
+                        ],
+                        "enabled": [
+                            "false"
+                        ]
+                    }
+                }
+            ],
+            []
+        ]
+        return_value_component_delete = [
+            None
+        ]
+        changed = True
+
+        set_module_args(module_args)
+
+        # Run the module
+
+        with mock_good_connection():
+            with patch_keycloak_api(get_components=return_value_components_get, delete_component=return_value_component_delete) \
+                    as (mock_get_components, mock_get_component, mock_create_component, mock_update_component, mock_delete_component):
+                with self.assertRaises(AnsibleExitJson) as exec_info:
+                    self.module.main()
+
+        self.assertEqual(len(mock_get_components.mock_calls), 2)
+        self.assertEqual(len(mock_get_component.mock_calls), 0)
+        self.assertEqual(len(mock_create_component.mock_calls), 0)
+        self.assertEqual(len(mock_update_component.mock_calls), 0)
+        self.assertEqual(len(mock_delete_component.mock_calls), 1)
+
+        # Verify that the module's changed status matches what is expected
+        self.assertIs(exec_info.exception.args[0]['changed'], changed)
+
+
+if __name__ == '__main__':
+    unittest.main()