From 97e2c3dec9fee886a37d029b08a554228809eaa2 Mon Sep 17 00:00:00 2001 From: Laurent Paumier <30328363+laurpaum@users.noreply.github.com> Date: Tue, 31 Aug 2021 07:07:53 +0200 Subject: [PATCH] Keycloak: add identity providers management (#3210) * init new module * update * add mappers * improve mappers * tests * fix tests * fix tests * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * Update plugins/modules/identity/keycloak/keycloak_identity_provider.py Co-authored-by: Felix Fontein * fix typos * update botmeta * improve change detection * fix tests * add integration tests * remove updateProfileFirstLoginMode parameter Co-authored-by: Laurent PAUMIER Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + .../identity/keycloak/keycloak.py | 164 +++++ .../keycloak/keycloak_identity_provider.py | 608 ++++++++++++++++++ plugins/modules/keycloak_identity_provider.py | 1 + .../keycloak_identity_provider/aliases | 1 + .../keycloak_identity_provider/tasks/main.yml | 171 +++++ .../keycloak_identity_provider/vars/main.yml | 7 + .../test_keycloak_identity_provider.py | 495 ++++++++++++++ 8 files changed, 1449 insertions(+) create mode 100644 plugins/modules/identity/keycloak/keycloak_identity_provider.py create mode 120000 plugins/modules/keycloak_identity_provider.py create mode 100644 tests/integration/targets/keycloak_identity_provider/aliases create mode 100644 tests/integration/targets/keycloak_identity_provider/tasks/main.yml create mode 100644 tests/integration/targets/keycloak_identity_provider/vars/main.yml create mode 100644 tests/unit/plugins/modules/identity/keycloak/test_keycloak_identity_provider.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 6055224145..0d2922182b 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -506,6 +506,8 @@ files: maintainers: Gaetan2907 $modules/identity/keycloak/keycloak_group.py: maintainers: adamgoossens + $modules/identity/keycloak/keycloak_identity_provider.py: + maintainers: laurpaum $modules/identity/keycloak/keycloak_realm.py: maintainers: kris2kris $modules/identity/keycloak/keycloak_role.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index d53a29ba10..5ddb1320b9 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -78,6 +78,11 @@ URL_AUTHENTICATION_EXECUTION_RAISE_PRIORITY = "{url}/admin/realms/{realm}/authen URL_AUTHENTICATION_EXECUTION_LOWER_PRIORITY = "{url}/admin/realms/{realm}/authentication/executions/{id}/lower-priority" URL_AUTHENTICATION_CONFIG = "{url}/admin/realms/{realm}/authentication/config/{id}" +URL_IDENTITY_PROVIDERS = "{url}/admin/realms/{realm}/identity-provider/instances" +URL_IDENTITY_PROVIDER = "{url}/admin/realms/{realm}/identity-provider/instances/{alias}" +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}" + def keycloak_argument_spec(): """ @@ -1437,3 +1442,162 @@ class KeycloakAPI(object): except Exception as e: self.module.fail_json(msg='Could not get executions for authentication flow %s in realm %s: %s' % (config["alias"], realm, str(e))) + + def get_identity_providers(self, realm='master'): + """ Fetch representations for identity providers in a realm + :param realm: realm to be queried + :return: list of representations for identity providers + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return json.loads(to_native(open_url(idps_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 identity providers for realm %s: %s' + % (realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of identity providers for realm %s: %s' + % (realm, str(e))) + + def get_identity_provider(self, alias, realm='master'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param alias: Alias of the identity provider to fetch. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(idp_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 identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + + def create_identity_provider(self, idprep, realm='master'): + """ Create an identity provider. + :param idprep: Identity provider representation of the idp to be created. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + idps_url = URL_IDENTITY_PROVIDERS.format(url=self.baseurl, realm=realm) + try: + return open_url(idps_url, method='POST', headers=self.restheaders, + data=json.dumps(idprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create identity provider %s in realm %s: %s' + % (idprep['alias'], realm, str(e))) + + def update_identity_provider(self, idprep, realm='master'): + """ Update an existing identity provider. + :param idprep: Identity provider representation of the idp to be updated. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=idprep['alias']) + try: + return open_url(idp_url, method='PUT', headers=self.restheaders, + data=json.dumps(idprep), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update identity provider %s in realm %s: %s' + % (idprep['alias'], realm, str(e))) + + def delete_identity_provider(self, alias, realm='master'): + """ Delete an identity provider. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + idp_url = URL_IDENTITY_PROVIDER.format(url=self.baseurl, realm=realm, alias=alias) + try: + return open_url(idp_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Unable to delete identity provider %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_identity_provider_mappers(self, alias, realm='master'): + """ Fetch representations for identity provider mappers + :param alias: Alias of the identity provider. + :param realm: realm to be queried + :return: list of representations for identity provider mappers + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return json.loads(to_native(open_url(mappers_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 identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not obtain list of identity provider mappers for idp %s in realm %s: %s' + % (alias, realm, str(e))) + + def get_identity_provider_mapper(self, mid, alias, realm='master'): + """ Fetch identity provider representation from a realm using the idp's alias. + If the identity provider does not exist, None is returned. + :param mid: Unique ID of the mapper to fetch. + :param alias: Alias of the identity provider. + :param realm: Realm in which the identity provider resides; default 'master'. + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return json.loads(to_native(open_url(mapper_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 mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + except Exception as e: + self.module.fail_json(msg='Could not fetch mapper %s for identity provider %s in realm %s: %s' + % (mid, alias, realm, str(e))) + + def create_identity_provider_mapper(self, mapper, alias, realm='master'): + """ Create an identity provider mapper. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be created. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return: HTTPResponse object on success + """ + mappers_url = URL_IDENTITY_PROVIDER_MAPPERS.format(url=self.baseurl, realm=realm, alias=alias) + try: + return open_url(mappers_url, method='POST', headers=self.restheaders, + data=json.dumps(mapper), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not create identity provider mapper %s for idp %s in realm %s: %s' + % (mapper['name'], alias, realm, str(e))) + + def update_identity_provider_mapper(self, mapper, alias, realm='master'): + """ Update an existing identity provider. + :param mapper: IdentityProviderMapperRepresentation of the mapper to be updated. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + :return HTTPResponse object on success + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mapper['id']) + try: + return open_url(mapper_url, method='PUT', headers=self.restheaders, + data=json.dumps(mapper), validate_certs=self.validate_certs) + except Exception as e: + self.module.fail_json(msg='Could not update mapper %s for identity provider %s in realm %s: %s' + % (mapper['id'], alias, realm, str(e))) + + def delete_identity_provider_mapper(self, mid, alias, realm='master'): + """ Delete an identity provider. + :param mid: Unique ID of the mapper to delete. + :param alias: Alias of the identity provider. + :param realm: Realm in which this identity provider resides, default "master". + """ + mapper_url = URL_IDENTITY_PROVIDER_MAPPER.format(url=self.baseurl, realm=realm, alias=alias, id=mid) + try: + return open_url(mapper_url, method='DELETE', headers=self.restheaders, + validate_certs=self.validate_certs) + 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))) diff --git a/plugins/modules/identity/keycloak/keycloak_identity_provider.py b/plugins/modules/identity/keycloak/keycloak_identity_provider.py new file mode 100644 index 0000000000..f56aeb9067 --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_identity_provider.py @@ -0,0 +1,608 @@ +#!/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_identity_provider + +short_description: Allows administration of Keycloak identity providers via Keycloak API + +version_added: 3.6.0 + +description: + - This module allows you to add, remove or modify Keycloak identity providers 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 identity provider. + - On C(present), the identity provider will be created if it does not yet exist, or updated with the parameters you provide. + - On C(absent), the identity provider will be removed if it exists. + default: 'present' + type: str + choices: + - present + - absent + + realm: + description: + - The Keycloak realm under which this identity provider resides. + default: 'master' + type: str + + alias: + description: + - The alias uniquely identifies an identity provider and it is also used to build the redirect URI. + required: true + type: str + + display_name: + description: + - Friendly name for identity provider. + aliases: + - displayName + type: str + + enabled: + description: + - Enable/disable this identity provider. + type: bool + + store_token: + description: + - Enable/disable whether tokens must be stored after authenticating users. + aliases: + - storeToken + type: bool + + add_read_token_role_on_create: + description: + - Enable/disable whether new users can read any stored tokens. This assigns the C(broker.read-token) role. + aliases: + - addReadTokenRoleOnCreate + type: bool + + trust_email: + description: + - If enabled, email provided by this provider is not verified even if verification is enabled for the realm. + aliases: + - trustEmail + type: bool + + link_only: + description: + - If true, users cannot log in through this provider. They can only link to this provider. + This is useful if you don't want to allow login from the provider, but want to integrate with a provider. + aliases: + - linkOnly + type: bool + + first_broker_login_flow_alias: + description: + - Alias of authentication flow, which is triggered after first login with this identity provider. + aliases: + - firstBrokerLoginFlowAlias + type: str + + post_broker_login_flow_alias: + description: + - Alias of authentication flow, which is triggered after each login with this identity provider. + aliases: + - postBrokerLoginFlowAlias + type: str + + authenticate_by_default: + description: + - Specifies if this identity provider should be used by default for authentication even before displaying login screen. + aliases: + - authenticateByDefault + type: bool + + provider_id: + description: + - Protocol used by this provider (supported values are C(oidc) or C(saml)). + aliases: + - providerId + type: str + + config: + description: + - Dict specifying the configuration options for the provider; the contents differ depending on the value of I(providerId). + Examples are given below for C(oidc) and C(saml). It is easiest to obtain valid config values by dumping an already-existing + identity provider configuration through check-mode in the I(existing) field. + type: dict + suboptions: + hide_on_login_page: + description: + - If hidden, login with this provider is possible only if requested explicitly, for example using the C(kc_idp_hint) parameter. + aliases: + - hideOnLoginPage + type: bool + + gui_order: + description: + - Number defining order of the provider in GUI (for example, on Login page). + aliases: + - guiOrder + type: int + + sync_mode: + description: + - Default sync mode for all mappers. The sync mode determines when user data will be synced using the mappers. + aliases: + - syncMode + type: str + + issuer: + description: + - The issuer identifier for the issuer of the response. If not provided, no validation will be performed. + type: str + + authorizationUrl: + description: + - The Authorization URL. + type: str + + tokenUrl: + description: + - The Token URL. + type: str + + logoutUrl: + description: + - End session endpoint to use to logout user from external IDP. + type: str + + userInfoUrl: + description: + - The User Info URL. + type: str + + clientAuthMethod: + description: + - The client authentication method. + type: str + + clientId: + description: + - The client or client identifier registered within the identity provider. + type: str + + clientSecret: + description: + - The client or client secret registered within the identity provider. + type: str + + defaultScope: + description: + - The scopes to be sent when asking for authorization. + type: str + + validateSignature: + description: + - Enable/disable signature validation of external IDP signatures. + type: bool + + useJwksUrl: + description: + - If the switch is on, identity provider public keys will be downloaded from given JWKS URL. + type: bool + + jwksUrl: + description: + - URL where identity provider keys in JWK format are stored. See JWK specification for more details. + type: str + + entityId: + description: + - The Entity ID that will be used to uniquely identify this SAML Service Provider. + type: str + + singleSignOnServiceUrl: + description: + - The URL that must be used to send authentication requests (SAML AuthnRequest). + type: str + + singleLogoutServiceUrl: + description: + - The URL that must be used to send logout requests. + type: str + + backchannelSupported: + description: + - Does the external IDP support backchannel logout? + type: str + + nameIDPolicyFormat: + description: + - Specifies the URI reference corresponding to a name identifier format. + type: str + + principalType: + description: + - Way to identify and track external users from the assertion. + type: str + + 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. + type: str + + identityProviderAlias: + description: + - Alias of the identity provider for this mapper. + type: str + + identityProviderMapper: + description: + - Type of mapper. + 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 OIDC identity provider, authentication with credentials + community.general.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: oidc-idp + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + issuer: https://idp.example.com + authorizationUrl: https://idp.example.com/auth + tokenUrl: https://idp.example.com/token + userInfoUrl: https://idp.example.com/userinfo + clientAuthMethod: client_secret_post + clientId: my-client + clientSecret: secret + +- name: Create SAML identity provider, authentication with credentials + community.general.keycloak_identity_provider: + state: present + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: admin + auth_password: admin + realm: myrealm + alias: saml-idp + display_name: SAML IdP + enabled: true + provider_id: saml + config: + entityId: https://auth.example.com/auth/realms/myrealm + singleSignOnServiceUrl: https://idp.example.com/login + wantAuthnRequestsSigned: true + wantAssertionsSigned: true +''' + +RETURN = ''' +msg: + description: Message as to what action was taken + returned: always + type: str + sample: "Identity provider my-idp has been created" + +proposed: + description: Representation of proposed changes to identity provider + returned: always + type: dict + sample: { + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "secret", + "issuer": "https://idp.example.com", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "providerId": "oidc" + } + +existing: + description: Representation of existing identity provider + returned: always + type: dict + sample: { + "addReadTokenRoleOnCreate": false, + "alias": "my-idp", + "authenticateByDefault": false, + "config": { + "authorizationUrl": "https://old.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "**********", + "issuer": "https://old.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://old.example.com/token", + "userInfoUrl": "https://old.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": true, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c", + "linkOnly": false, + "providerId": "oidc", + "storeToken": false, + "trustEmail": false, + } + +end_state: + description: Representation of identity provider after module execution + returned: always + type: dict + sample: { + "addReadTokenRoleOnCreate": false, + "alias": "my-idp", + "authenticateByDefault": false, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "**********", + "issuer": "https://idp.example.com", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": true, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "4d28d7e3-1b80-45bb-8a30-5822bf55aa1c", + "linkOnly": false, + "providerId": "oidc", + "storeToken": false, + "trustEmail": false, + } + +''' + +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 + + +def sanitize(idp): + result = idp.copy() + if 'config' in result: + result['config'] = sanitize(result['config']) + if 'clientSecret' in result: + result['clientSecret'] = '**********' + return result + + +def get_identity_provider_with_mappers(kc, alias, realm): + idp = kc.get_identity_provider(alias, realm) + if idp is not None: + idp['mappers'] = sorted(kc.get_identity_provider_mappers(alias, realm), key=lambda x: x.get('name')) + if idp is None: + idp = dict() + return idp + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = keycloak_argument_spec() + + mapper_spec = dict( + id=dict(type='str'), + name=dict(type='str'), + identityProviderAlias=dict(type='str'), + identityProviderMapper=dict(type='str'), + config=dict(type='dict'), + ) + + meta_args = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + realm=dict(type='str', default='master'), + alias=dict(type='str', required=True), + add_read_token_role_on_create=dict(type='bool', aliases=['addReadTokenRoleOnCreate']), + authenticate_by_default=dict(type='bool', aliases=['authenticateByDefault']), + config=dict(type='dict'), + display_name=dict(type='str', aliases=['displayName']), + enabled=dict(type='bool'), + first_broker_login_flow_alias=dict(type='str', aliases=['firstBrokerLoginFlowAlias']), + link_only=dict(type='bool', aliases=['linkOnly']), + post_broker_login_flow_alias=dict(type='str', aliases=['postBrokerLoginFlowAlias']), + provider_id=dict(type='str', aliases=['providerId']), + store_token=dict(type='bool', aliases=['storeToken']), + trust_email=dict(type='bool', aliases=['trustEmail']), + 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=([['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') + alias = module.params.get('alias') + state = module.params.get('state') + + # convert module parameters to client representation parameters (if they belong in there) + idp_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 identity provider already exist? + before_idp = get_identity_provider_with_mappers(kc, alias, realm) + + # build a changeset + changeset = dict() + + for param in idp_params: + new_param_value = module.params.get(param) + old_value = before_idp[camel(param)] if camel(param) in before_idp else None + if new_param_value != old_value: + changeset[camel(param)] = new_param_value + + # special handling of mappers list to allow change detection + changeset['mappers'] = before_idp.get('mappers', list()) + if module.params.get('mappers') is not None: + for new_mapper in module.params.get('mappers'): + old_mapper = next((x for x in changeset['mappers'] if x['name'] == new_mapper['name']), None) + new_mapper = dict((k, v) for k, v in new_mapper.items() if new_mapper[k] is not None) + if old_mapper is not None: + old_mapper.update(new_mapper) + else: + changeset['mappers'].append(new_mapper) + # remove mappers if not present in module params + changeset['mappers'] = [x for x in changeset['mappers'] + if [y for y in module.params.get('mappers', []) if y['name'] == x['name']] != []] + + # prepare the new representation + updated_idp = before_idp.copy() + updated_idp.update(changeset) + + result['proposed'] = sanitize(changeset) + result['existing'] = sanitize(before_idp) + + # if before_idp is none, the identity provider doesn't exist. + if before_idp == dict(): + if state == 'absent': + # nothing to do. + if module._diff: + result['diff'] = dict(before='', after='') + result['changed'] = False + result['end_state'] = dict() + result['msg'] = 'Identity provider does not exist; doing nothing.' + module.exit_json(**result) + + # for 'present', create a new identity provider. + result['changed'] = True + + if module._diff: + result['diff'] = dict(before='', after=sanitize(updated_idp)) + + if module.check_mode: + module.exit_json(**result) + + # do it for real! + updated_idp = updated_idp.copy() + mappers = updated_idp.pop('mappers', []) + kc.create_identity_provider(updated_idp, realm) + for mapper in mappers: + kc.create_identity_provider_mapper(mapper, alias, realm) + after_idp = get_identity_provider_with_mappers(kc, alias, realm) + + result['end_state'] = sanitize(after_idp) + + result['msg'] = 'Identity provider {alias} has been created'.format(alias=alias) + module.exit_json(**result) + + else: + if state == 'present': + # no changes + if updated_idp == before_idp: + result['changed'] = False + result['end_state'] = sanitize(updated_idp) + result['msg'] = "No changes required to identity provider {alias}.".format(alias=alias) + module.exit_json(**result) + + # update the existing role + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_idp), after=sanitize(updated_idp)) + + if module.check_mode: + module.exit_json(**result) + + # do the update + updated_idp = updated_idp.copy() + updated_mappers = updated_idp.pop('mappers', []) + kc.update_identity_provider(updated_idp, realm) + for mapper in updated_mappers: + if mapper.get('id') is not None: + kc.update_identity_provider_mapper(mapper, alias, realm) + else: + kc.create_identity_provider_mapper(mapper, alias, realm) + for mapper in [x for x in before_idp['mappers'] + if [y for y in updated_mappers if y["name"] == x['name']] == []]: + kc.delete_identity_provider_mapper(mapper['id'], alias, realm) + + after_idp = get_identity_provider_with_mappers(kc, alias, realm) + + result['end_state'] = sanitize(after_idp) + + result['msg'] = "Identity provider {alias} has been updated".format(alias=alias) + module.exit_json(**result) + + elif state == 'absent': + result['changed'] = True + + if module._diff: + result['diff'] = dict(before=sanitize(before_idp), after='') + + if module.check_mode: + module.exit_json(**result) + + # delete for real + kc.delete_identity_provider(alias, realm) + + result['end_state'] = dict() + + result['msg'] = "Identity provider {alias} has been deleted".format(alias=alias) + module.exit_json(**result) + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_identity_provider.py b/plugins/modules/keycloak_identity_provider.py new file mode 120000 index 0000000000..6beed321db --- /dev/null +++ b/plugins/modules/keycloak_identity_provider.py @@ -0,0 +1 @@ +./identity/keycloak/keycloak_identity_provider.py \ No newline at end of file diff --git a/tests/integration/targets/keycloak_identity_provider/aliases b/tests/integration/targets/keycloak_identity_provider/aliases new file mode 100644 index 0000000000..ad7ccf7ada --- /dev/null +++ b/tests/integration/targets/keycloak_identity_provider/aliases @@ -0,0 +1 @@ +unsupported diff --git a/tests/integration/targets/keycloak_identity_provider/tasks/main.yml b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml new file mode 100644 index 0000000000..5bc0bc3fa0 --- /dev/null +++ b/tests/integration/targets/keycloak_identity_provider/tasks/main.yml @@ -0,0 +1,171 @@ +--- +- 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 identity provider + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp }}" + display_name: OpenID Connect IdP + enabled: true + provider_id: oidc + config: + issuer: https://idp.example.com + authorizationUrl: https://idp.example.com/auth + tokenUrl: https://idp.example.com/token + userInfoUrl: https://idp.example.com/userinfo + clientAuthMethod: client_secret_post + clientId: clientid + clientSecret: clientsecret + syncMode: FORCE + mappers: + - name: "first_name" + identityProviderAlias: "oidc-idp" + identityProviderMapper: "oidc-user-attribute-idp-mapper" + config: + claim: "first_name" + user.attribute: "first_name" + syncMode: "INHERIT" + - name: "last_name" + identityProviderAlias: "oidc-idp" + identityProviderMapper: "oidc-user-attribute-idp-mapper" + config: + claim: "last_name" + user.attribute: "last_name" + syncMode: "INHERIT" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider created + assert: + that: + - result is changed + - result.existing == {} + - result.end_state.alias == "{{ idp }}" + - result.end_state.mappers != [] + +- name: Update existing identity provider (no change) + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp }}" + enabled: true + provider_id: oidc + config: + issuer: https://idp.example.com + authorizationUrl: https://idp.example.com/auth + tokenUrl: https://idp.example.com/token + userInfoUrl: https://idp.example.com/userinfo + clientAuthMethod: client_secret_post + clientId: clientid + clientSecret: "**********" + syncMode: FORCE + mappers: + - name: "first_name" + identityProviderAlias: "oidc-idp" + identityProviderMapper: "oidc-user-attribute-idp-mapper" + config: + claim: "first_name" + user.attribute: "first_name" + syncMode: "INHERIT" + - name: "last_name" + identityProviderAlias: "oidc-idp" + identityProviderMapper: "oidc-user-attribute-idp-mapper" + config: + claim: "last_name" + user.attribute: "last_name" + syncMode: "INHERIT" + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider unchanged + assert: + that: + - result is not changed + +- name: Update existing identity provider (with change) + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp }}" + enabled: false + state: present + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider updated + assert: + that: + - result is changed + - result.existing.enabled == true + - result.end_state.enabled == false + +- name: Delete existing identity provider + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider deleted + assert: + that: + - result is changed + - result.end_state == {} + +- name: Delete absent identity provider + community.general.keycloak_identity_provider: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + alias: "{{ idp }}" + state: absent + register: result + +- name: Debug + debug: + var: result + +- name: Assert identity provider unchanged + assert: + that: + - result is not changed + - result.end_state == {} diff --git a/tests/integration/targets/keycloak_identity_provider/vars/main.yml b/tests/integration/targets/keycloak_identity_provider/vars/main.yml new file mode 100644 index 0000000000..bd37149b31 --- /dev/null +++ b/tests/integration/targets/keycloak_identity_provider/vars/main.yml @@ -0,0 +1,7 @@ +--- +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm +idp: myidp diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_identity_provider.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_identity_provider.py new file mode 100644 index 0000000000..8666b61759 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_identity_provider.py @@ -0,0 +1,495 @@ +# -*- 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_identity_provider + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_identity_provider, create_identity_provider=None, update_identity_provider=None, delete_identity_provider=None, + get_identity_provider_mappers=None, create_identity_provider_mapper=None, update_identity_provider_mapper=None, + delete_identity_provider_mapper=None): + """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server + + Patches the `login` and `_post_json` methods + + Keyword arguments are passed to the mock object that patches `_post_json` + + No arguments are passed to the mock object that patches `login` because no tests require it + + Example:: + + with patch_ipa(return_value={}) as (mock_login, mock_post): + ... + """ + + obj = keycloak_identity_provider.KeycloakAPI + with patch.object(obj, 'get_identity_provider', side_effect=get_identity_provider) \ + as mock_get_identity_provider: + with patch.object(obj, 'create_identity_provider', side_effect=create_identity_provider) \ + as mock_create_identity_provider: + with patch.object(obj, 'update_identity_provider', side_effect=update_identity_provider) \ + as mock_update_identity_provider: + with patch.object(obj, 'delete_identity_provider', side_effect=delete_identity_provider) \ + as mock_delete_identity_provider: + with patch.object(obj, 'get_identity_provider_mappers', side_effect=get_identity_provider_mappers) \ + as mock_get_identity_provider_mappers: + with patch.object(obj, 'create_identity_provider_mapper', side_effect=create_identity_provider_mapper) \ + as mock_create_identity_provider_mapper: + with patch.object(obj, 'update_identity_provider_mapper', side_effect=update_identity_provider_mapper) \ + as mock_update_identity_provider_mapper: + with patch.object(obj, 'delete_identity_provider_mapper', side_effect=delete_identity_provider_mapper) \ + as mock_delete_identity_provider_mapper: + yield mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, \ + mock_delete_identity_provider, mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, \ + mock_update_identity_provider_mapper, mock_delete_identity_provider_mapper + + +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 TestKeycloakIdentityProvider(ModuleTestCase): + def setUp(self): + super(TestKeycloakIdentityProvider, self).setUp() + self.module = keycloak_identity_provider + + def test_create_when_absent(self): + """Add a new identity provider""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'alias': 'oidc-idp', + 'display_name': 'OpenID Connect IdP', + 'enabled': True, + 'provider_id': 'oidc', + 'config': { + 'issuer': 'https://idp.example.com', + 'authorizationUrl': 'https://idp.example.com/auth', + 'tokenUrl': 'https://idp.example.com/token', + 'userInfoUrl': 'https://idp.example.com/userinfo', + 'clientAuthMethod': 'client_secret_post', + 'clientId': 'my-client', + 'clientSecret': 'secret', + 'syncMode': "FORCE", + }, + 'mappers': [{ + 'name': "first_name", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'config': { + 'claim': "first_name", + 'user.attribute': "first_name", + 'syncMode': "INHERIT", + } + }, { + 'name': "last_name", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'config': { + 'claim': "last_name", + 'user.attribute': "last_name", + 'syncMode': "INHERIT", + } + }] + } + return_value_idp_get = [ + None, + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "no_log", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + return_value_mappers_get = [ + [{ + "config": { + "claim": "first_name", + "syncMode": "INHERIT", + "user.attribute": "first_name" + }, + "id": "5fde49bb-93bd-4f5d-97d6-c5d0c1d07aef", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "first_name" + }, { + "config": { + "claim": "last_name", + "syncMode": "INHERIT", + "user.attribute": "last_name" + }, + "id": "f00c61e0-34d9-4bed-82d1-7e45acfefc09", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "last_name" + }] + ] + return_value_idp_created = [None] + return_value_mapper_created = [None, None] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, + create_identity_provider=return_value_idp_created, create_identity_provider_mapper=return_value_mapper_created) \ + as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, + mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, + mock_delete_identity_provider_mapper): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_identity_provider.mock_calls), 2) + self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 1) + self.assertEqual(len(mock_create_identity_provider.mock_calls), 1) + self.assertEqual(len(mock_create_identity_provider_mapper.mock_calls), 2) + + # 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 identity provider""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'alias': 'oidc-idp', + 'display_name': 'OpenID Connect IdP', + 'enabled': True, + 'provider_id': 'oidc', + 'config': { + 'issuer': 'https://idp.example.com', + 'authorizationUrl': 'https://idp.example.com/auth', + 'tokenUrl': 'https://idp.example.com/token', + 'userInfoUrl': 'https://idp.example.com/userinfo', + 'clientAuthMethod': 'client_secret_post', + 'clientId': 'my-client', + 'clientSecret': 'secret', + 'syncMode': "FORCE" + }, + 'mappers': [{ + 'name': "first_name", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'config': { + 'claim': "first_name", + 'user.attribute': "first_name", + 'syncMode': "INHERIT", + } + }, { + 'name': "last_name", + 'identityProviderAlias': "oidc-idp", + 'identityProviderMapper': "oidc-user-attribute-idp-mapper", + 'config': { + 'claim': "last_name", + 'user.attribute': "last_name", + 'syncMode': "INHERIT", + } + }] + } + return_value_idp_get = [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "no_log", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP changeme", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + }, + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "no_log", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + } + ] + return_value_mappers_get = [ + [{ + "config": { + "claim": "first_name_changeme", + "syncMode": "INHERIT", + "user.attribute": "first_name_changeme" + }, + "id": "5fde49bb-93bd-4f5d-97d6-c5d0c1d07aef", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "first_name" + }], + [{ + "config": { + "claim": "first_name", + "syncMode": "INHERIT", + "user.attribute": "first_name" + }, + "id": "5fde49bb-93bd-4f5d-97d6-c5d0c1d07aef", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "first_name" + }, { + "config": { + "claim": "last_name", + "syncMode": "INHERIT", + "user.attribute": "last_name" + }, + "id": "f00c61e0-34d9-4bed-82d1-7e45acfefc09", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "last_name" + }] + ] + return_value_idp_updated = [None] + return_value_mapper_updated = [None] + return_value_mapper_created = [None] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, + update_identity_provider=return_value_idp_updated, update_identity_provider_mapper=return_value_mapper_updated, + create_identity_provider_mapper=return_value_mapper_created) \ + as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, + mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, + mock_delete_identity_provider_mapper): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_identity_provider.mock_calls), 2) + self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 2) + self.assertEqual(len(mock_update_identity_provider.mock_calls), 1) + self.assertEqual(len(mock_update_identity_provider_mapper.mock_calls), 1) + self.assertEqual(len(mock_create_identity_provider_mapper.mock_calls), 1) + + # 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 identity provider""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'alias': 'oidc-idp', + 'state': 'absent', + } + return_value_idp_get = [None] + changed = False + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_identity_provider=return_value_idp_get) \ + as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, + mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, + mock_delete_identity_provider_mapper): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_identity_provider.mock_calls), 1) + self.assertEqual(len(mock_delete_identity_provider.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 identity provider""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'auth_client_id': 'admin-cli', + 'validate_certs': True, + 'realm': 'realm-name', + 'alias': 'oidc-idp', + 'state': 'absent', + } + return_value_idp_get = [ + { + "addReadTokenRoleOnCreate": False, + "alias": "oidc-idp", + "authenticateByDefault": False, + "config": { + "authorizationUrl": "https://idp.example.com/auth", + "clientAuthMethod": "client_secret_post", + "clientId": "my-client", + "clientSecret": "no_log", + "issuer": "https://idp.example.com", + "syncMode": "FORCE", + "tokenUrl": "https://idp.example.com/token", + "userInfoUrl": "https://idp.example.com/userinfo" + }, + "displayName": "OpenID Connect IdP", + "enabled": True, + "firstBrokerLoginFlowAlias": "first broker login", + "internalId": "7ab437d5-f2bb-4ecc-91a8-315349454da6", + "linkOnly": False, + "providerId": "oidc", + "storeToken": False, + "trustEmail": False, + }, + None + ] + return_value_mappers_get = [ + [{ + "config": { + "claim": "email", + "syncMode": "INHERIT", + "user.attribute": "email" + }, + "id": "5fde49bb-93bd-4f5d-97d6-c5d0c1d07aef", + "identityProviderAlias": "oidc-idp", + "identityProviderMapper": "oidc-user-attribute-idp-mapper", + "name": "email" + }] + ] + return_value_idp_deleted = [None] + changed = True + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_identity_provider=return_value_idp_get, get_identity_provider_mappers=return_value_mappers_get, + delete_identity_provider=return_value_idp_deleted) \ + as (mock_get_identity_provider, mock_create_identity_provider, mock_update_identity_provider, mock_delete_identity_provider, + mock_get_identity_provider_mappers, mock_create_identity_provider_mapper, mock_update_identity_provider_mapper, + mock_delete_identity_provider_mapper): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_identity_provider.mock_calls), 1) + self.assertEqual(len(mock_get_identity_provider_mappers.mock_calls), 1) + self.assertEqual(len(mock_delete_identity_provider.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()