#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) Ansible project # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later 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/20.0.2/rest-api/index.html). attributes: check_mode: support: full diff_mode: support: full options: state: description: - State of the user federation. - On V(present), the user federation will be created if it does not yet exist, or updated with the parameters you provide. - On V(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 O(name). type: str name: description: - Display name of provider when linked in admin console. type: str provider_id: description: - Provider for this user federation. Built-in providers are V(ldap), V(kerberos), and V(sssd). Custom user storage providers can also be used. aliases: - providerId type: str provider_type: description: - Component type for user federation (only supported value is V(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 remove_unspecified_mappers: description: - Remove mappers that are not specified in the configuration for this federation. - Set to V(false) to keep mappers that are not listed in O(mappers). type: bool default: true version_added: 9.4.0 config: description: - Dict specifying the configuration options for the provider; the contents differ depending on the value of O(provider_id). Examples are given below for V(ldap), V(kerberos) and V(sssd). It is easiest to obtain valid config values by dumping an already-existing user federation configuration through check-mode in the RV(existing) field. - The value V(sssd) has been supported since community.general 4.2.0. 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 V(true), LDAP users will be imported into Keycloak DB and synced by the configured sync policies. default: true type: bool editMode: description: - V(READ_ONLY) is a read-only LDAP store. V(WRITABLE) means data will be synced back to LDAP on demand. V(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). - Use short name. For instance, write V(rhds) for "Red Hat Directory Server". type: str usernameLDAPAttribute: description: - Name of LDAP attribute, which is mapped as Keycloak username. For many LDAP server vendors it can be V(uid). For Active directory it can be V(sAMAccountName) or V(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 V(cn) as RDN attribute when username attribute might be V(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 V(entryUUID); however some are different. For example for Active directory it should be V(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 V(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. V(always) means that it will always use it. V(never) means that it will not use it. V(ldapsOnly) 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 V(fine) (trace connection creation and removal) and V(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 V(plain) and V(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 krbPrincipalAttribute: description: - Name of the LDAP attribute, which refers to Kerberos principal. This is used to lookup appropriate LDAP user after successful Kerberos/SPNEGO authentication in Keycloak. When this is empty, the LDAP user will be looked based on LDAP username corresponding to the first part of his Kerberos principal. For instance, for principal C(john@KEYCLOAK.ORG), it will assume that LDAP username is V(john). type: str version_added: 8.1.0 serverPrincipal: description: - Full name of server principal for HTTP service including server and domain name. For example V(HTTP/host.foo.org@FOO.ORG). Use V(*) 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 V(/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 V(user-attribute-ldap-mapper)). type: str providerType: description: - Component type for this mapper. type: str default: org.keycloak.storage.ldap.mappers.LDAPStorageMapper config: description: - Dict specifying the configuration options for the mapper; the contents differ depending on the value of I(identityProviderMapper). # TODO: what is identityProviderMapper above??? type: dict extends_documentation_fragment: - community.general.keycloak - community.general.attributes 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: Create sssd 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-sssd state: present provider_id: sssd provider_type: org.keycloak.storage.UserStorageProvider config: priority: 0 enabled: true cachePolicy: DEFAULT - 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 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: on success 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'] = {k: v[0] for k, v in compcopy['config'].items()} if 'bindCredential' in compcopy['config']: compcopy['config']['bindCredential'] = '**********' # an empty string is valid for krbPrincipalAttribute but is filtered out in diff if 'krbPrincipalAttribute' not in compcopy['config']: compcopy['config']['krbPrincipalAttribute'] = '' if 'mappers' in compcopy: for mapper in compcopy['mappers']: if 'config' in mapper: mapper['config'] = {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'), krbPrincipalAttribute=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', default='org.keycloak.storage.ldap.mappers.LDAPStorageMapper'), 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']), provider_type=dict(type='str', aliases=['providerType'], default='org.keycloak.storage.UserStorageProvider'), parent_id=dict(type='str', aliases=['parentId']), remove_unspecified_mappers=dict(type='bool', default=True), 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'] = { 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) # Filter and map the parameters names that apply comp_params = [x for x in module.params if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm', 'mappers', 'remove_unspecified_mappers'] and module.params.get(x) is not None] # See if it already exists in Keycloak if cid is None: found = kc.get_components(urlencode(dict(type='org.keycloak.storage.UserStorageProvider', 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 = {} # if user federation exists, get associated mappers if cid is not None and before_comp: before_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') # Build a proposed changeset from parameters given to this module changeset = {} 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 = [{k: v for k, v in x.items() if v 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'] in ['kerberos', 'sssd']: module.fail_json(msg='Cannot configure mappers for {type} provider.'.format(type=module.params['provider_id'])) for change in module.params['mappers']: change = {k: v for k, v in change.items() if v 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 = {} elif change.get('id') is not None: old_mapper = next((before_mapper for before_mapper in before_mapper.get('mappers', []) if before_mapper["id"] == change['id']), None) if old_mapper is None: old_mapper = {} else: found = [before_mapper for before_mapper in before_comp.get('mappers', []) if before_mapper['name'] == change['name']] 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 = {} new_mapper = old_mapper.copy() new_mapper.update(change) # changeset contains all desired mappers: those existing, to update or to create if changeset.get('mappers') is None: changeset['mappers'] = list() changeset['mappers'].append(new_mapper) changeset['mappers'] = sorted(changeset['mappers'], key=lambda x: x.get('name') or '') # to keep unspecified existing mappers we add them to the desired mappers list, unless they're already present if not module.params['remove_unspecified_mappers'] and 'mappers' in before_comp: changeset_mapper_ids = [mapper['id'] for mapper in changeset['mappers'] if 'id' in mapper] changeset['mappers'].extend([mapper for mapper in before_comp['mappers'] if mapper['id'] not in changeset_mapper_ids]) # Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis) desired_comp = before_comp.copy() desired_comp.update(changeset) result['proposed'] = sanitize(changeset) result['existing'] = sanitize(before_comp) # Cater for when it doesn't exist (an empty dict) if not before_comp: if state == 'absent': # Do nothing and exit if module._diff: result['diff'] = dict(before='', after='') result['changed'] = False result['end_state'] = {} result['msg'] = 'User federation does not exist; doing nothing.' module.exit_json(**result) # Process a creation result['changed'] = True if module.check_mode: if module._diff: result['diff'] = dict(before='', after=sanitize(desired_comp)) module.exit_json(**result) # create it desired_mappers = desired_comp.pop('mappers', []) after_comp = kc.create_component(desired_comp, realm) cid = after_comp['id'] updated_mappers = [] # when creating a user federation, keycloak automatically creates default mappers default_mappers = kc.get_components(urlencode(dict(parent=cid)), realm) # create new mappers or update existing default mappers for desired_mapper in desired_mappers: found = [default_mapper for default_mapper in default_mappers if default_mapper['name'] == desired_mapper['name']] if len(found) > 1: module.fail_json(msg='Found multiple mappers with name `{name}`. Cannot continue.'.format(name=desired_mapper['name'])) if len(found) == 1: old_mapper = found[0] else: old_mapper = {} new_mapper = old_mapper.copy() new_mapper.update(desired_mapper) if new_mapper.get('id') is not None: kc.update_component(new_mapper, realm) updated_mappers.append(new_mapper) else: if new_mapper.get('parentId') is None: new_mapper['parentId'] = cid updated_mappers.append(kc.create_component(new_mapper, realm)) if module.params['remove_unspecified_mappers']: # we remove all unwanted default mappers # we use ids so we dont accidently remove one of the previously updated default mapper for default_mapper in default_mappers: if not default_mapper['id'] in [x['id'] for x in updated_mappers]: kc.delete_component(default_mapper['id'], realm) after_comp['mappers'] = kc.get_components(urlencode(dict(parent=cid)), realm) if module._diff: result['diff'] = dict(before='', after=sanitize(after_comp)) result['end_state'] = sanitize(after_comp) result['msg'] = "User federation {id} has been created".format(id=cid) module.exit_json(**result) else: if state == 'present': # Process an update # no changes if desired_comp == before_comp: result['changed'] = False result['end_state'] = sanitize(desired_comp) result['msg'] = "No changes required to user federation {id}.".format(id=cid) module.exit_json(**result) # doing an update result['changed'] = True if module._diff: result['diff'] = dict(before=sanitize(before_comp), after=sanitize(desired_comp)) if module.check_mode: module.exit_json(**result) # do the update desired_mappers = desired_comp.pop('mappers', []) kc.update_component(desired_comp, realm) for before_mapper in before_comp.get('mappers', []): # remove unwanted existing mappers that will not be updated if not before_mapper['id'] in [x['id'] for x in desired_mappers if 'id' in x]: kc.delete_component(before_mapper['id'], realm) for mapper in desired_mappers: if mapper in before_comp.get('mappers', []): continue if mapper.get('id') is not None: kc.update_component(mapper, realm) else: if mapper.get('parentId') is None: mapper['parentId'] = desired_comp['id'] kc.create_component(mapper, realm) after_comp = kc.get_component(cid, realm) after_comp['mappers'] = sorted(kc.get_components(urlencode(dict(parent=cid)), realm), key=lambda x: x.get('name') or '') after_comp_sanitized = sanitize(after_comp) before_comp_sanitized = sanitize(before_comp) result['end_state'] = after_comp_sanitized if module._diff: result['diff'] = dict(before=before_comp_sanitized, after=after_comp_sanitized) result['changed'] = before_comp_sanitized != after_comp_sanitized result['msg'] = "User federation {id} has been updated".format(id=cid) module.exit_json(**result) elif state == 'absent': # Process a deletion result['changed'] = True if module._diff: result['diff'] = dict(before=sanitize(before_comp), after='') if module.check_mode: module.exit_json(**result) # delete it kc.delete_component(cid, realm) result['end_state'] = {} result['msg'] = "User federation {id} has been deleted".format(id=cid) module.exit_json(**result) if __name__ == '__main__': main()