From 2589e9a0300ddd78730eb03f10c5d89e844463f5 Mon Sep 17 00:00:00 2001 From: Laurent Paumier <30328363+laurpaum@users.noreply.github.com> Date: Mon, 20 Sep 2021 19:19:42 +0200 Subject: [PATCH] Add keycloak_user_federation module (#3340) * new module * fix unit tests * fix documentation * more fixes * fix linefeeds * Apply suggestions from code review Co-authored-by: Felix Fontein * use true/false instead of True/False * Apply suggestions from code review Co-authored-by: Felix Fontein * fix result content + rename variable * urlencode parameters Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + .../identity/keycloak/keycloak.py | 93 ++ .../keycloak/keycloak_user_federation.py | 979 ++++++++++++++++++ plugins/modules/keycloak_user_federation.py | 1 + .../targets/keycloak_user_federation/aliases | 1 + .../keycloak_user_federation/tasks/main.yml | 225 ++++ .../keycloak_user_federation/vars/main.yml | 7 + .../keycloak/test_keycloak_user_federation.py | 581 +++++++++++ 8 files changed, 1889 insertions(+) create mode 100644 plugins/modules/identity/keycloak/keycloak_user_federation.py create mode 120000 plugins/modules/keycloak_user_federation.py create mode 100644 tests/integration/targets/keycloak_user_federation/aliases create mode 100644 tests/integration/targets/keycloak_user_federation/tasks/main.yml create mode 100644 tests/integration/targets/keycloak_user_federation/vars/main.yml create mode 100644 tests/unit/plugins/modules/identity/keycloak/test_keycloak_user_federation.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 09cd8b8f3c..e1285097da 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -521,6 +521,8 @@ files: maintainers: kris2kris $modules/identity/keycloak/keycloak_role.py: maintainers: laurpaum + $modules/identity/keycloak/keycloak_user_federation.py: + maintainers: laurpaum $modules/identity/onepassword_info.py: maintainers: Rylon $modules/identity/opendj/opendj_backendprop.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 5ddb1320b9..ae3b014306 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -83,6 +83,9 @@ URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/ 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 @@ class KeycloakAPI(object): 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 index 0000000000..4298334924 --- /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 index 0000000000..e996a5c87d --- /dev/null +++ b/plugins/modules/keycloak_user_federation.py @@ -0,0 +1 @@ +identity/keycloak/keycloak_user_federation.py \ No newline at end of file diff --git a/tests/integration/targets/keycloak_user_federation/aliases b/tests/integration/targets/keycloak_user_federation/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /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 index 0000000000..bc81fcdec3 --- /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 index 0000000000..2a31f9efcd --- /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 index 0000000000..674efeb237 --- /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()