mirror of
https://github.com/ansible-collections/community.general.git
synced 2024-09-14 20:13:21 +02:00
Keycloak: add clientscope management (#2905)
* Add new keycloak_clienscope module * Add description and protocol parameter + Indentation Fix * Add protocolMappers parameter * Add documentation and Fix updatating of protocolMappers * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/identity/keycloak/keycloak_clientscope.py Co-authored-by: Felix Fontein <felix@fontein.de> * Add sanitize_cr(clientscoperep) function to sanitize the clientscope representation * Add unit tests for clientscope Keycloak module * Apply suggestions from code review Co-authored-by: Felix Fontein <felix@fontein.de>
This commit is contained in:
parent
d7c6ba89f8
commit
4a392372a8
4 changed files with 1345 additions and 0 deletions
|
@ -57,6 +57,11 @@ URL_CLIENTTEMPLATES = "{url}/admin/realms/{realm}/client-templates"
|
||||||
URL_GROUPS = "{url}/admin/realms/{realm}/groups"
|
URL_GROUPS = "{url}/admin/realms/{realm}/groups"
|
||||||
URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}"
|
URL_GROUP = "{url}/admin/realms/{realm}/groups/{groupid}"
|
||||||
|
|
||||||
|
URL_CLIENTSCOPES = "{url}/admin/realms/{realm}/client-scopes"
|
||||||
|
URL_CLIENTSCOPE = "{url}/admin/realms/{realm}/client-scopes/{id}"
|
||||||
|
URL_CLIENTSCOPE_PROTOCOLMAPPERS = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models"
|
||||||
|
URL_CLIENTSCOPE_PROTOCOLMAPPER = "{url}/admin/realms/{realm}/client-scopes/{id}/protocol-mappers/models/{mapper_id}"
|
||||||
|
|
||||||
URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows"
|
URL_AUTHENTICATION_FLOWS = "{url}/admin/realms/{realm}/authentication/flows"
|
||||||
URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}"
|
URL_AUTHENTICATION_FLOW = "{url}/admin/realms/{realm}/authentication/flows/{id}"
|
||||||
URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy"
|
URL_AUTHENTICATION_FLOW_COPY = "{url}/admin/realms/{realm}/authentication/flows/{copyfrom}/copy"
|
||||||
|
@ -511,6 +516,239 @@ class KeycloakAPI(object):
|
||||||
self.module.fail_json(msg='Could not delete client template %s in realm %s: %s'
|
self.module.fail_json(msg='Could not delete client template %s in realm %s: %s'
|
||||||
% (id, realm, str(e)))
|
% (id, realm, str(e)))
|
||||||
|
|
||||||
|
def get_clientscopes(self, realm="master"):
|
||||||
|
""" Fetch the name and ID of all clientscopes on the Keycloak server.
|
||||||
|
|
||||||
|
To fetch the full data of the group, make a subsequent call to
|
||||||
|
get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return.
|
||||||
|
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'.
|
||||||
|
:return The clientscopes of this realm (default "master")
|
||||||
|
"""
|
||||||
|
clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm)
|
||||||
|
try:
|
||||||
|
return json.loads(to_native(open_url(clientscopes_url, method="GET", headers=self.restheaders,
|
||||||
|
validate_certs=self.validate_certs).read()))
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not fetch list of clientscopes in realm %s: %s"
|
||||||
|
% (realm, str(e)))
|
||||||
|
|
||||||
|
def get_clientscope_by_clientscopeid(self, cid, realm="master"):
|
||||||
|
""" Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID.
|
||||||
|
|
||||||
|
If the clientscope does not exist, None is returned.
|
||||||
|
|
||||||
|
gid is a UUID provided by the Keycloak API
|
||||||
|
:param cid: UUID of the clientscope to be returned
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'.
|
||||||
|
"""
|
||||||
|
clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=cid)
|
||||||
|
try:
|
||||||
|
return json.loads(to_native(open_url(clientscope_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 clientscope %s in realm %s: %s"
|
||||||
|
% (cid, realm, str(e)))
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not clientscope group %s in realm %s: %s"
|
||||||
|
% (cid, realm, str(e)))
|
||||||
|
|
||||||
|
def get_clientscope_by_name(self, name, realm="master"):
|
||||||
|
""" Fetch a keycloak clientscope within a realm based on its name.
|
||||||
|
|
||||||
|
The Keycloak API does not allow filtering of the clientscopes resource by name.
|
||||||
|
As a result, this method first retrieves the entire list of clientscopes - name and ID -
|
||||||
|
then performs a second query to fetch the group.
|
||||||
|
|
||||||
|
If the clientscope does not exist, None is returned.
|
||||||
|
:param name: Name of the clientscope to fetch.
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_clientscopes = self.get_clientscopes(realm=realm)
|
||||||
|
|
||||||
|
for clientscope in all_clientscopes:
|
||||||
|
if clientscope['name'] == name:
|
||||||
|
return self.get_clientscope_by_clientscopeid(clientscope['id'], realm=realm)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not fetch clientscope %s in realm %s: %s"
|
||||||
|
% (name, realm, str(e)))
|
||||||
|
|
||||||
|
def create_clientscope(self, clientscoperep, realm="master"):
|
||||||
|
""" Create a Keycloak clientscope.
|
||||||
|
|
||||||
|
:param clientscoperep: a ClientScopeRepresentation of the clientscope to be created. Must contain at minimum the field name.
|
||||||
|
:return: HTTPResponse object on success
|
||||||
|
"""
|
||||||
|
clientscopes_url = URL_CLIENTSCOPES.format(url=self.baseurl, realm=realm)
|
||||||
|
try:
|
||||||
|
return open_url(clientscopes_url, method='POST', headers=self.restheaders,
|
||||||
|
data=json.dumps(clientscoperep), validate_certs=self.validate_certs)
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not create clientscope %s in realm %s: %s"
|
||||||
|
% (clientscoperep['name'], realm, str(e)))
|
||||||
|
|
||||||
|
def update_clientscope(self, clientscoperep, realm="master"):
|
||||||
|
""" Update an existing clientscope.
|
||||||
|
|
||||||
|
:param grouprep: A GroupRepresentation of the updated group.
|
||||||
|
:return HTTPResponse object on success
|
||||||
|
"""
|
||||||
|
clientscope_url = URL_CLIENTSCOPE.format(url=self.baseurl, realm=realm, id=clientscoperep['id'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return open_url(clientscope_url, method='PUT', headers=self.restheaders,
|
||||||
|
data=json.dumps(clientscoperep), validate_certs=self.validate_certs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg='Could not update clientscope %s in realm %s: %s'
|
||||||
|
% (clientscoperep['name'], realm, str(e)))
|
||||||
|
|
||||||
|
def delete_clientscope(self, name=None, cid=None, realm="master"):
|
||||||
|
""" Delete a clientscope. One of name or cid must be provided.
|
||||||
|
|
||||||
|
Providing the clientscope ID is preferred as it avoids a second lookup to
|
||||||
|
convert a clientscope name to an ID.
|
||||||
|
|
||||||
|
:param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID.
|
||||||
|
:param cid: The ID of the clientscope (preferred to name).
|
||||||
|
:param realm: The realm in which this group resides, default "master".
|
||||||
|
"""
|
||||||
|
|
||||||
|
if cid is None and name is None:
|
||||||
|
# prefer an exception since this is almost certainly a programming error in the module itself.
|
||||||
|
raise Exception("Unable to delete group - one of group ID or name must be provided.")
|
||||||
|
|
||||||
|
# only lookup the name if cid isn't provided.
|
||||||
|
# in the case that both are provided, prefer the ID, since it's one
|
||||||
|
# less lookup.
|
||||||
|
if cid is None and name is not None:
|
||||||
|
for clientscope in self.get_clientscopes(realm=realm):
|
||||||
|
if clientscope['name'] == name:
|
||||||
|
cid = clientscope['id']
|
||||||
|
break
|
||||||
|
|
||||||
|
# if the group doesn't exist - no problem, nothing to delete.
|
||||||
|
if cid is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# should have a good cid by here.
|
||||||
|
clientscope_url = URL_CLIENTSCOPE.format(realm=realm, id=cid, url=self.baseurl)
|
||||||
|
try:
|
||||||
|
return open_url(clientscope_url, method='DELETE', headers=self.restheaders,
|
||||||
|
validate_certs=self.validate_certs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Unable to delete clientscope %s: %s" % (cid, str(e)))
|
||||||
|
|
||||||
|
def get_clientscope_protocolmappers(self, cid, realm="master"):
|
||||||
|
""" Fetch the name and ID of all clientscopes on the Keycloak server.
|
||||||
|
|
||||||
|
To fetch the full data of the group, make a subsequent call to
|
||||||
|
get_clientscope_by_clientscopeid, passing in the ID of the group you wish to return.
|
||||||
|
|
||||||
|
:param cid: id of clientscope (not name).
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'.
|
||||||
|
:return The protocolmappers of this realm (default "master")
|
||||||
|
"""
|
||||||
|
protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(id=cid, url=self.baseurl, realm=realm)
|
||||||
|
try:
|
||||||
|
return json.loads(to_native(open_url(protocolmappers_url, method="GET", headers=self.restheaders,
|
||||||
|
validate_certs=self.validate_certs).read()))
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not fetch list of protocolmappers in realm %s: %s"
|
||||||
|
% (realm, str(e)))
|
||||||
|
|
||||||
|
def get_clientscope_protocolmapper_by_protocolmapperid(self, pid, cid, realm="master"):
|
||||||
|
""" Fetch a keycloak clientscope from the provided realm using the clientscope's unique ID.
|
||||||
|
|
||||||
|
If the clientscope does not exist, None is returned.
|
||||||
|
|
||||||
|
gid is a UUID provided by the Keycloak API
|
||||||
|
|
||||||
|
:param cid: UUID of the protocolmapper to be returned
|
||||||
|
:param cid: UUID of the clientscope to be returned
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'.
|
||||||
|
"""
|
||||||
|
protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=pid)
|
||||||
|
try:
|
||||||
|
return json.loads(to_native(open_url(protocolmapper_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 protocolmapper %s in realm %s: %s"
|
||||||
|
% (pid, realm, str(e)))
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s"
|
||||||
|
% (cid, realm, str(e)))
|
||||||
|
|
||||||
|
def get_clientscope_protocolmapper_by_name(self, cid, name, realm="master"):
|
||||||
|
""" Fetch a keycloak clientscope within a realm based on its name.
|
||||||
|
|
||||||
|
The Keycloak API does not allow filtering of the clientscopes resource by name.
|
||||||
|
As a result, this method first retrieves the entire list of clientscopes - name and ID -
|
||||||
|
then performs a second query to fetch the group.
|
||||||
|
|
||||||
|
If the clientscope does not exist, None is returned.
|
||||||
|
:param cid: Id of the clientscope (not name).
|
||||||
|
:param name: Name of the protocolmapper to fetch.
|
||||||
|
:param realm: Realm in which the clientscope resides; default 'master'
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
all_protocolmappers = self.get_clientscope_protocolmappers(cid, realm=realm)
|
||||||
|
|
||||||
|
for protocolmapper in all_protocolmappers:
|
||||||
|
if protocolmapper['name'] == name:
|
||||||
|
return self.get_clientscope_protocolmapper_by_protocolmapperid(protocolmapper['id'], cid, realm=realm)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not fetch protocolmapper %s in realm %s: %s"
|
||||||
|
% (name, realm, str(e)))
|
||||||
|
|
||||||
|
def create_clientscope_protocolmapper(self, cid, mapper_rep, realm="master"):
|
||||||
|
""" Create a Keycloak clientscope protocolmapper.
|
||||||
|
|
||||||
|
:param cid: Id of the clientscope.
|
||||||
|
:param mapper_rep: a ProtocolMapperRepresentation of the protocolmapper to be created. Must contain at minimum the field name.
|
||||||
|
:return: HTTPResponse object on success
|
||||||
|
"""
|
||||||
|
protocolmappers_url = URL_CLIENTSCOPE_PROTOCOLMAPPERS.format(url=self.baseurl, id=cid, realm=realm)
|
||||||
|
try:
|
||||||
|
return open_url(protocolmappers_url, method='POST', headers=self.restheaders,
|
||||||
|
data=json.dumps(mapper_rep), validate_certs=self.validate_certs)
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg="Could not create protocolmapper %s in realm %s: %s"
|
||||||
|
% (mapper_rep['name'], realm, str(e)))
|
||||||
|
|
||||||
|
def update_clientscope_protocolmappers(self, cid, mapper_rep, realm="master"):
|
||||||
|
""" Update an existing clientscope.
|
||||||
|
|
||||||
|
:param cid: Id of the clientscope.
|
||||||
|
:param mapper_rep: A ProtocolMapperRepresentation of the updated protocolmapper.
|
||||||
|
:return HTTPResponse object on success
|
||||||
|
"""
|
||||||
|
protocolmapper_url = URL_CLIENTSCOPE_PROTOCOLMAPPER.format(url=self.baseurl, realm=realm, id=cid, mapper_id=mapper_rep['id'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return open_url(protocolmapper_url, method='PUT', headers=self.restheaders,
|
||||||
|
data=json.dumps(mapper_rep), validate_certs=self.validate_certs)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s'
|
||||||
|
% (mapper_rep, realm, str(e)))
|
||||||
|
|
||||||
def get_groups(self, realm="master"):
|
def get_groups(self, realm="master"):
|
||||||
""" Fetch the name and ID of all groups on the Keycloak server.
|
""" Fetch the name and ID of all groups on the Keycloak server.
|
||||||
|
|
||||||
|
|
492
plugins/modules/identity/keycloak/keycloak_clientscope.py
Normal file
492
plugins/modules/identity/keycloak/keycloak_clientscope.py
Normal file
|
@ -0,0 +1,492 @@
|
||||||
|
#!/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_clientscope
|
||||||
|
|
||||||
|
short_description: Allows administration of Keycloak client_scopes via Keycloak API
|
||||||
|
|
||||||
|
version_added: 3.4.0
|
||||||
|
|
||||||
|
description:
|
||||||
|
- This module allows you to add, remove or modify Keycloak client_scopes 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/8.0/rest-api/index.html).
|
||||||
|
|
||||||
|
- Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will
|
||||||
|
be returned that way by this module. You may pass single values for attributes when calling the module,
|
||||||
|
and this will be translated into a list suitable for the API.
|
||||||
|
|
||||||
|
- When updating a client_scope, where possible provide the client_scope ID to the module. This removes a lookup
|
||||||
|
to the API to translate the name into the client_scope ID.
|
||||||
|
|
||||||
|
|
||||||
|
options:
|
||||||
|
state:
|
||||||
|
description:
|
||||||
|
- State of the client_scope.
|
||||||
|
- On C(present), the client_scope will be created if it does not yet exist, or updated with the parameters you provide.
|
||||||
|
- On C(absent), the client_scope will be removed if it exists.
|
||||||
|
default: 'present'
|
||||||
|
type: str
|
||||||
|
choices:
|
||||||
|
- present
|
||||||
|
- absent
|
||||||
|
|
||||||
|
name:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- Name of the client_scope.
|
||||||
|
- This parameter is required only when creating or updating the client_scope.
|
||||||
|
|
||||||
|
realm:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- They Keycloak realm under which this client_scope resides.
|
||||||
|
default: 'master'
|
||||||
|
|
||||||
|
id:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- The unique identifier for this client_scope.
|
||||||
|
- This parameter is not required for updating or deleting a client_scope but
|
||||||
|
providing it will reduce the number of API calls required.
|
||||||
|
|
||||||
|
description:
|
||||||
|
type: str
|
||||||
|
description:
|
||||||
|
- Description for this client_scope.
|
||||||
|
- This parameter is not required for updating or deleting a client_scope.
|
||||||
|
|
||||||
|
protocol:
|
||||||
|
description:
|
||||||
|
- Type of client.
|
||||||
|
choices: ['openid-connect', 'saml', 'wsfed']
|
||||||
|
type: str
|
||||||
|
|
||||||
|
protocol_mappers:
|
||||||
|
description:
|
||||||
|
- A list of dicts defining protocol mappers for this client.
|
||||||
|
- This is 'protocolMappers' in the Keycloak REST API.
|
||||||
|
aliases:
|
||||||
|
- protocolMappers
|
||||||
|
type: list
|
||||||
|
elements: dict
|
||||||
|
suboptions:
|
||||||
|
protocol:
|
||||||
|
description:
|
||||||
|
- This specifies for which protocol this protocol mapper
|
||||||
|
- is active.
|
||||||
|
choices: ['openid-connect', 'saml', 'wsfed']
|
||||||
|
type: str
|
||||||
|
|
||||||
|
protocolMapper:
|
||||||
|
description:
|
||||||
|
- "The Keycloak-internal name of the type of this protocol-mapper. While an exhaustive list is
|
||||||
|
impossible to provide since this may be extended through SPIs by the user of Keycloak,
|
||||||
|
by default Keycloak as of 3.4 ships with at least:"
|
||||||
|
- C(docker-v2-allow-all-mapper)
|
||||||
|
- C(oidc-address-mapper)
|
||||||
|
- C(oidc-full-name-mapper)
|
||||||
|
- C(oidc-group-membership-mapper)
|
||||||
|
- C(oidc-hardcoded-claim-mapper)
|
||||||
|
- C(oidc-hardcoded-role-mapper)
|
||||||
|
- C(oidc-role-name-mapper)
|
||||||
|
- C(oidc-script-based-protocol-mapper)
|
||||||
|
- C(oidc-sha256-pairwise-sub-mapper)
|
||||||
|
- C(oidc-usermodel-attribute-mapper)
|
||||||
|
- C(oidc-usermodel-client-role-mapper)
|
||||||
|
- C(oidc-usermodel-property-mapper)
|
||||||
|
- C(oidc-usermodel-realm-role-mapper)
|
||||||
|
- C(oidc-usersessionmodel-note-mapper)
|
||||||
|
- C(saml-group-membership-mapper)
|
||||||
|
- C(saml-hardcode-attribute-mapper)
|
||||||
|
- C(saml-hardcode-role-mapper)
|
||||||
|
- C(saml-role-list-mapper)
|
||||||
|
- C(saml-role-name-mapper)
|
||||||
|
- C(saml-user-attribute-mapper)
|
||||||
|
- C(saml-user-property-mapper)
|
||||||
|
- C(saml-user-session-note-mapper)
|
||||||
|
- An exhaustive list of available mappers on your installation can be obtained on
|
||||||
|
the admin console by going to Server Info -> Providers and looking under
|
||||||
|
'protocol-mapper'.
|
||||||
|
type: str
|
||||||
|
|
||||||
|
name:
|
||||||
|
description:
|
||||||
|
- The name of this protocol mapper.
|
||||||
|
type: str
|
||||||
|
|
||||||
|
id:
|
||||||
|
description:
|
||||||
|
- Usually a UUID specifying the internal ID of this protocol mapper instance.
|
||||||
|
type: str
|
||||||
|
|
||||||
|
config:
|
||||||
|
description:
|
||||||
|
- Dict specifying the configuration options for the protocol mapper; the
|
||||||
|
contents differ depending on the value of I(protocolMapper) and are not documented
|
||||||
|
other than by the source of the mappers and its parent class(es). An example is given
|
||||||
|
below. It is easiest to obtain valid config values by dumping an already-existing
|
||||||
|
protocol mapper configuration through check-mode in the C(existing) return value.
|
||||||
|
type: dict
|
||||||
|
|
||||||
|
attributes:
|
||||||
|
type: dict
|
||||||
|
description:
|
||||||
|
- A dict of key/value pairs to set as custom attributes for the client_scope.
|
||||||
|
- Values may be single values (for example a string) or a list of strings.
|
||||||
|
|
||||||
|
extends_documentation_fragment:
|
||||||
|
- community.general.keycloak
|
||||||
|
|
||||||
|
|
||||||
|
author:
|
||||||
|
- Gaëtan Daubresse (@Gaetan2907)
|
||||||
|
'''
|
||||||
|
|
||||||
|
EXAMPLES = '''
|
||||||
|
- name: Create a Keycloak client_scopes, authentication with credentials
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
name: my-new-kc-clientscope
|
||||||
|
realm: MyCustomRealm
|
||||||
|
state: present
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
auth_realm: master
|
||||||
|
auth_username: USERNAME
|
||||||
|
auth_password: PASSWORD
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Create a Keycloak client_scopes, authentication with token
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
name: my-new-kc-clientscope
|
||||||
|
realm: MyCustomRealm
|
||||||
|
state: present
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
token: TOKEN
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Delete a keycloak client_scopes
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
|
||||||
|
state: absent
|
||||||
|
realm: MyCustomRealm
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
auth_realm: master
|
||||||
|
auth_username: USERNAME
|
||||||
|
auth_password: PASSWORD
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Delete a Keycloak client_scope based on name
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
name: my-clientscope-for-deletion
|
||||||
|
state: absent
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
auth_realm: master
|
||||||
|
auth_username: USERNAME
|
||||||
|
auth_password: PASSWORD
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Update the name of a Keycloak client_scope
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
id: '9d59aa76-2755-48c6-b1af-beb70a82c3cd'
|
||||||
|
name: an-updated-kc-clientscope-name
|
||||||
|
state: present
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
auth_realm: master
|
||||||
|
auth_username: USERNAME
|
||||||
|
auth_password: PASSWORD
|
||||||
|
delegate_to: localhost
|
||||||
|
|
||||||
|
- name: Create a Keycloak client_scope with some custom attributes
|
||||||
|
community.general.keycloak_clientscope:
|
||||||
|
auth_client_id: admin-cli
|
||||||
|
auth_keycloak_url: https://auth.example.com/auth
|
||||||
|
auth_realm: master
|
||||||
|
auth_username: USERNAME
|
||||||
|
auth_password: PASSWORD
|
||||||
|
name: my-new_clientscope
|
||||||
|
description: description-of-clientscope
|
||||||
|
protocol: openid-connect
|
||||||
|
protocol_mappers:
|
||||||
|
- config:
|
||||||
|
access.token.claim: True
|
||||||
|
claim.name: "family_name"
|
||||||
|
id.token.claim: True
|
||||||
|
jsonType.label: String
|
||||||
|
user.attribute: lastName
|
||||||
|
userinfo.token.claim: True
|
||||||
|
name: family name
|
||||||
|
protocol: openid-connect
|
||||||
|
protocolMapper: oidc-usermodel-property-mapper
|
||||||
|
- config:
|
||||||
|
attribute.name: Role
|
||||||
|
attribute.nameformat: Basic
|
||||||
|
single: false
|
||||||
|
name: role list
|
||||||
|
protocol: saml
|
||||||
|
protocolMapper: saml-role-list-mapper
|
||||||
|
attributes:
|
||||||
|
attrib1: value1
|
||||||
|
attrib2: value2
|
||||||
|
attrib3:
|
||||||
|
- with
|
||||||
|
- numerous
|
||||||
|
- individual
|
||||||
|
- list
|
||||||
|
- items
|
||||||
|
delegate_to: localhost
|
||||||
|
'''
|
||||||
|
|
||||||
|
RETURN = '''
|
||||||
|
msg:
|
||||||
|
description: Message as to what action was taken
|
||||||
|
returned: always
|
||||||
|
type: str
|
||||||
|
sample: "Client_scope testclientscope has been updated"
|
||||||
|
|
||||||
|
proposed:
|
||||||
|
description: client_scope representation of proposed changes to client_scope
|
||||||
|
returned: always
|
||||||
|
type: dict
|
||||||
|
sample: {
|
||||||
|
clientId: "test"
|
||||||
|
}
|
||||||
|
existing:
|
||||||
|
description: client_scope representation of existing client_scope (sample is truncated)
|
||||||
|
returned: always
|
||||||
|
type: dict
|
||||||
|
sample: {
|
||||||
|
"adminUrl": "http://www.example.com/admin_url",
|
||||||
|
"attributes": {
|
||||||
|
"request.object.signature.alg": "RS256",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end_state:
|
||||||
|
description: client_scope representation of client_scope after module execution (sample is truncated)
|
||||||
|
returned: always
|
||||||
|
type: dict
|
||||||
|
sample: {
|
||||||
|
"adminUrl": "http://www.example.com/admin_url",
|
||||||
|
"attributes": {
|
||||||
|
"request.object.signature.alg": "RS256",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \
|
||||||
|
keycloak_argument_spec, get_token, KeycloakError, is_struct_included
|
||||||
|
from ansible.module_utils.basic import AnsibleModule
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_cr(clientscoperep):
|
||||||
|
""" Removes probably sensitive details from a clientscoperep representation
|
||||||
|
|
||||||
|
:param clientscoperep: the clientscoperep dict to be sanitized
|
||||||
|
:return: sanitized clientrep dict
|
||||||
|
"""
|
||||||
|
result = clientscoperep.copy()
|
||||||
|
if 'secret' in result:
|
||||||
|
result['secret'] = 'no_log'
|
||||||
|
if 'attributes' in result:
|
||||||
|
if 'saml.signing.private.key' in result['attributes']:
|
||||||
|
result['attributes']['saml.signing.private.key'] = 'no_log'
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""
|
||||||
|
Module execution
|
||||||
|
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
argument_spec = keycloak_argument_spec()
|
||||||
|
|
||||||
|
protmapper_spec = dict(
|
||||||
|
id=dict(type='str'),
|
||||||
|
name=dict(type='str'),
|
||||||
|
protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']),
|
||||||
|
protocolMapper=dict(type='str'),
|
||||||
|
config=dict(type='dict'),
|
||||||
|
)
|
||||||
|
|
||||||
|
meta_args = dict(
|
||||||
|
state=dict(default='present', choices=['present', 'absent']),
|
||||||
|
realm=dict(default='master'),
|
||||||
|
id=dict(type='str'),
|
||||||
|
name=dict(type='str'),
|
||||||
|
description=dict(type='str'),
|
||||||
|
protocol=dict(type='str', choices=['openid-connect', 'saml', 'wsfed']),
|
||||||
|
attributes=dict(type='dict'),
|
||||||
|
protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']),
|
||||||
|
)
|
||||||
|
|
||||||
|
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')
|
||||||
|
cid = module.params.get('id')
|
||||||
|
name = module.params.get('name')
|
||||||
|
protocol_mappers = module.params.get('protocol_mappers')
|
||||||
|
|
||||||
|
before_clientscope = None # current state of the clientscope, for merging.
|
||||||
|
|
||||||
|
# does the clientscope already exist?
|
||||||
|
if cid is None:
|
||||||
|
before_clientscope = kc.get_clientscope_by_name(name, realm=realm)
|
||||||
|
else:
|
||||||
|
before_clientscope = kc.get_clientscope_by_clientscopeid(cid, realm=realm)
|
||||||
|
|
||||||
|
before_clientscope = {} if before_clientscope is None else before_clientscope
|
||||||
|
|
||||||
|
clientscope_params = [x for x in module.params
|
||||||
|
if x not in list(keycloak_argument_spec().keys()) + ['state', 'realm'] and
|
||||||
|
module.params.get(x) is not None]
|
||||||
|
|
||||||
|
# Build a proposed changeset from parameters given to this module
|
||||||
|
changeset = dict()
|
||||||
|
|
||||||
|
for clientscope_param in clientscope_params:
|
||||||
|
new_param_value = module.params.get(clientscope_param)
|
||||||
|
|
||||||
|
# some lists in the Keycloak API are sorted, some are not.
|
||||||
|
if isinstance(new_param_value, list):
|
||||||
|
if clientscope_param in ['attributes']:
|
||||||
|
try:
|
||||||
|
new_param_value = sorted(new_param_value)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
# Unfortunately, the ansible argument spec checker introduces variables with null values when
|
||||||
|
# they are not specified
|
||||||
|
if clientscope_param == 'protocol_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]
|
||||||
|
changeset[camel(clientscope_param)] = new_param_value
|
||||||
|
|
||||||
|
# prepare the new clientscope
|
||||||
|
updated_clientscope = before_clientscope.copy()
|
||||||
|
updated_clientscope.update(changeset)
|
||||||
|
|
||||||
|
# if before_clientscope is none, the clientscope doesn't exist.
|
||||||
|
if before_clientscope == {}:
|
||||||
|
if state == 'absent':
|
||||||
|
# nothing to do.
|
||||||
|
if module._diff:
|
||||||
|
result['diff'] = dict(before='', after='')
|
||||||
|
result['msg'] = 'Clientscope does not exist; doing nothing.'
|
||||||
|
result['end_state'] = dict()
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
# for 'present', create a new clientscope.
|
||||||
|
result['changed'] = True
|
||||||
|
if name is None:
|
||||||
|
module.fail_json(msg='name must be specified when creating a new clientscope')
|
||||||
|
|
||||||
|
if module._diff:
|
||||||
|
result['diff'] = dict(before='', after=sanitize_cr(updated_clientscope))
|
||||||
|
|
||||||
|
if module.check_mode:
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
# do it for real!
|
||||||
|
kc.create_clientscope(updated_clientscope, realm=realm)
|
||||||
|
after_clientscope = kc.get_clientscope_by_name(name, realm)
|
||||||
|
|
||||||
|
result['end_state'] = sanitize_cr(after_clientscope)
|
||||||
|
result['msg'] = 'Clientscope {name} has been created with ID {id}'.format(name=after_clientscope['name'],
|
||||||
|
id=after_clientscope['id'])
|
||||||
|
|
||||||
|
else:
|
||||||
|
if state == 'present':
|
||||||
|
# no changes
|
||||||
|
if updated_clientscope == before_clientscope:
|
||||||
|
result['changed'] = False
|
||||||
|
result['end_state'] = sanitize_cr(updated_clientscope)
|
||||||
|
result['msg'] = "No changes required to clientscope {name}.".format(name=before_clientscope['name'])
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
# update the existing clientscope
|
||||||
|
result['changed'] = True
|
||||||
|
|
||||||
|
if module._diff:
|
||||||
|
result['diff'] = dict(before=sanitize_cr(before_clientscope), after=sanitize_cr(updated_clientscope))
|
||||||
|
|
||||||
|
if module.check_mode:
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
# do the clientscope update
|
||||||
|
kc.update_clientscope(updated_clientscope, realm=realm)
|
||||||
|
|
||||||
|
# do the protocolmappers update
|
||||||
|
if protocol_mappers is not None:
|
||||||
|
for protocol_mapper in protocol_mappers:
|
||||||
|
# update if protocolmapper exist
|
||||||
|
current_protocolmapper = kc.get_clientscope_protocolmapper_by_name(updated_clientscope['id'], protocol_mapper['name'], realm=realm)
|
||||||
|
if current_protocolmapper is not None:
|
||||||
|
protocol_mapper['id'] = current_protocolmapper['id']
|
||||||
|
kc.update_clientscope_protocolmappers(updated_clientscope['id'], protocol_mapper, realm=realm)
|
||||||
|
# create otherwise
|
||||||
|
else:
|
||||||
|
kc.create_clientscope_protocolmapper(updated_clientscope['id'], protocol_mapper, realm=realm)
|
||||||
|
|
||||||
|
after_clientscope = kc.get_clientscope_by_clientscopeid(updated_clientscope['id'], realm=realm)
|
||||||
|
|
||||||
|
result['end_state'] = after_clientscope
|
||||||
|
result['msg'] = "Clientscope {id} has been updated".format(id=after_clientscope['id'])
|
||||||
|
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
elif state == 'absent':
|
||||||
|
result['end_state'] = dict()
|
||||||
|
|
||||||
|
if module._diff:
|
||||||
|
result['diff'] = dict(before=sanitize_cr(before_clientscope), after='')
|
||||||
|
|
||||||
|
if module.check_mode:
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
# delete for real
|
||||||
|
cid = before_clientscope['id']
|
||||||
|
kc.delete_clientscope(cid=cid, realm=realm)
|
||||||
|
|
||||||
|
result['changed'] = True
|
||||||
|
result['msg'] = "Clientscope {name} has been deleted".format(name=before_clientscope['name'])
|
||||||
|
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
module.exit_json(**result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
1
plugins/modules/keycloak_clientscope.py
Symbolic link
1
plugins/modules/keycloak_clientscope.py
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
identity/keycloak/keycloak_clientscope.py
|
|
@ -0,0 +1,614 @@
|
||||||
|
# -*- 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_clientscope
|
||||||
|
|
||||||
|
from itertools import count
|
||||||
|
|
||||||
|
from ansible.module_utils.six import StringIO
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def patch_keycloak_api(get_clientscope_by_name=None, get_clientscope_by_clientscopeid=None, create_clientscope=None,
|
||||||
|
update_clientscope=None, get_clientscope_protocolmapper_by_name=None,
|
||||||
|
update_clientscope_protocolmappers=None, create_clientscope_protocolmapper=None,
|
||||||
|
delete_clientscope=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):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
|
||||||
|
"""
|
||||||
|
get_clientscope_by_clientscopeid
|
||||||
|
delete_clientscope
|
||||||
|
"""
|
||||||
|
|
||||||
|
obj = keycloak_clientscope.KeycloakAPI
|
||||||
|
with patch.object(obj, 'get_clientscope_by_name', side_effect=get_clientscope_by_name) \
|
||||||
|
as mock_get_clientscope_by_name:
|
||||||
|
with patch.object(obj, 'get_clientscope_by_clientscopeid', side_effect=get_clientscope_by_clientscopeid) \
|
||||||
|
as mock_get_clientscope_by_clientscopeid:
|
||||||
|
with patch.object(obj, 'create_clientscope', side_effect=create_clientscope) \
|
||||||
|
as mock_create_clientscope:
|
||||||
|
with patch.object(obj, 'update_clientscope', return_value=update_clientscope) \
|
||||||
|
as mock_update_clientscope:
|
||||||
|
with patch.object(obj, 'get_clientscope_protocolmapper_by_name',
|
||||||
|
side_effect=get_clientscope_protocolmapper_by_name) \
|
||||||
|
as mock_get_clientscope_protocolmapper_by_name:
|
||||||
|
with patch.object(obj, 'update_clientscope_protocolmappers',
|
||||||
|
side_effect=update_clientscope_protocolmappers) \
|
||||||
|
as mock_update_clientscope_protocolmappers:
|
||||||
|
with patch.object(obj, 'create_clientscope_protocolmapper',
|
||||||
|
side_effect=create_clientscope_protocolmapper) \
|
||||||
|
as mock_create_clientscope_protocolmapper:
|
||||||
|
with patch.object(obj, 'delete_clientscope', side_effect=delete_clientscope) \
|
||||||
|
as mock_delete_clientscope:
|
||||||
|
yield mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope, \
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name, mock_update_clientscope_protocolmappers, \
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope
|
||||||
|
|
||||||
|
|
||||||
|
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 TestKeycloakAuthentication(ModuleTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestKeycloakAuthentication, self).setUp()
|
||||||
|
self.module = keycloak_clientscope
|
||||||
|
|
||||||
|
def test_create_clientscope(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'present',
|
||||||
|
'name': 'my-new-kc-clientscope'
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "73fec1d2-f032-410c-8177-583104d01305",
|
||||||
|
"name": "my-new-kc-clientscope"
|
||||||
|
}]
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 2)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 1)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 0)
|
||||||
|
|
||||||
|
# Verify that the module's changed status matches what is expected
|
||||||
|
self.assertIs(exec_info.exception.args[0]['changed'], changed)
|
||||||
|
|
||||||
|
def test_create_clientscope_idempotency(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'present',
|
||||||
|
'name': 'my-new-kc-clientscope'
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "73fec1d2-f032-410c-8177-583104d01305",
|
||||||
|
"name": "my-new-kc-clientscope"
|
||||||
|
}]
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 1)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 0)
|
||||||
|
|
||||||
|
# Verify that the module's changed status matches what is expected
|
||||||
|
self.assertIs(exec_info.exception.args[0]['changed'], changed)
|
||||||
|
|
||||||
|
def test_delete_clientscope(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'absent',
|
||||||
|
'name': 'my-new-kc-clientscope'
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "73fec1d2-f032-410c-8177-583104d01305",
|
||||||
|
"name": "my-new-kc-clientscope"
|
||||||
|
}]
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 1)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 1)
|
||||||
|
|
||||||
|
# Verify that the module's changed status matches what is expected
|
||||||
|
self.assertIs(exec_info.exception.args[0]['changed'], changed)
|
||||||
|
|
||||||
|
def test_delete_clientscope_idempotency(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'absent',
|
||||||
|
'name': 'my-new-kc-clientscope'
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [None]
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 1)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 0)
|
||||||
|
|
||||||
|
# Verify that the module's changed status matches what is expected
|
||||||
|
self.assertIs(exec_info.exception.args[0]['changed'], changed)
|
||||||
|
|
||||||
|
def test_create_clientscope_with_protocolmappers(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'present',
|
||||||
|
'name': 'my-new-kc-clientscope',
|
||||||
|
'protocolMappers': [
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'true',
|
||||||
|
'id.token.claim': 'true',
|
||||||
|
'access.token.claim': 'true',
|
||||||
|
'userinfo.token.claim': 'true',
|
||||||
|
'claim.name': 'protocol1',
|
||||||
|
},
|
||||||
|
'name': 'protocol1',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'false',
|
||||||
|
'id.token.claim': 'false',
|
||||||
|
'access.token.claim': 'false',
|
||||||
|
'userinfo.token.claim': 'false',
|
||||||
|
'claim.name': 'protocol2',
|
||||||
|
},
|
||||||
|
'name': 'protocol2',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'true',
|
||||||
|
'id.token.claim': 'false',
|
||||||
|
'access.token.claim': 'true',
|
||||||
|
'userinfo.token.claim': 'false',
|
||||||
|
'claim.name': 'protocol3',
|
||||||
|
},
|
||||||
|
'name': 'protocol3',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "890ec72e-fe1d-4308-9f27-485ef7eaa182",
|
||||||
|
"name": "my-new-kc-clientscope",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "false",
|
||||||
|
"claim.name": "protocol2",
|
||||||
|
"full.path": "false",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"userinfo.token.claim": "false"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "a7f19adb-cc58-41b1-94ce-782dc255139b",
|
||||||
|
"name": "protocol2",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "protocol3",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"userinfo.token.claim": "false"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "2103a559-185a-40f4-84ae-9ab311d5b812",
|
||||||
|
"name": "protocol3",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "protocol1",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "bbf6390f-e95f-4c20-882b-9dad328363b9",
|
||||||
|
"name": "protocol1",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 2)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 1)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 0)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 0)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 0)
|
||||||
|
|
||||||
|
# Verify that the module's changed status matches what is expected
|
||||||
|
self.assertIs(exec_info.exception.args[0]['changed'], changed)
|
||||||
|
|
||||||
|
def test_update_clientscope_with_protocolmappers(self):
|
||||||
|
"""Add a new authentication flow from copy of an other flow"""
|
||||||
|
|
||||||
|
module_args = {
|
||||||
|
'auth_keycloak_url': 'http://keycloak.url/auth',
|
||||||
|
'auth_username': 'admin',
|
||||||
|
'auth_password': 'admin',
|
||||||
|
'auth_realm': 'master',
|
||||||
|
'realm': 'realm-name',
|
||||||
|
'state': 'present',
|
||||||
|
'name': 'my-new-kc-clientscope',
|
||||||
|
'protocolMappers': [
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'false',
|
||||||
|
'id.token.claim': 'false',
|
||||||
|
'access.token.claim': 'false',
|
||||||
|
'userinfo.token.claim': 'false',
|
||||||
|
'claim.name': 'protocol1_updated',
|
||||||
|
},
|
||||||
|
'name': 'protocol1',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'true',
|
||||||
|
'id.token.claim': 'false',
|
||||||
|
'access.token.claim': 'false',
|
||||||
|
'userinfo.token.claim': 'false',
|
||||||
|
'claim.name': 'protocol2_updated',
|
||||||
|
},
|
||||||
|
'name': 'protocol2',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'config': {
|
||||||
|
'full.path': 'true',
|
||||||
|
'id.token.claim': 'true',
|
||||||
|
'access.token.claim': 'true',
|
||||||
|
'userinfo.token.claim': 'true',
|
||||||
|
'claim.name': 'protocol3_updated',
|
||||||
|
},
|
||||||
|
'name': 'protocol3',
|
||||||
|
'protocolMapper': 'oidc-group-membership-mapper',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return_value_get_clientscope_by_name = [{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "890ec72e-fe1d-4308-9f27-485ef7eaa182",
|
||||||
|
"name": "my-new-kc-clientscope",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "groups",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "e077007a-367a-444f-91ef-70277a1d868d",
|
||||||
|
"name": "groups",
|
||||||
|
"protocol": "saml",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "groups",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "06c518aa-c627-43cc-9a82-d8467b508d34",
|
||||||
|
"name": "groups",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "groups",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "1d03c557-d97e-40f4-ac35-6cecd74ea70d",
|
||||||
|
"name": "groups",
|
||||||
|
"protocol": "wsfed",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
return_value_get_clientscope_by_clientscopeid = [{
|
||||||
|
"attributes": {},
|
||||||
|
"id": "2286032f-451e-44d5-8be6-e45aac7983a1",
|
||||||
|
"name": "my-new-kc-clientscope",
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "protocol1_updated",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"userinfo.token.claim": "false"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "a7f19adb-cc58-41b1-94ce-782dc255139b",
|
||||||
|
"name": "protocol2",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "protocol1_updated",
|
||||||
|
"full.path": "true",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"userinfo.token.claim": "false"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "2103a559-185a-40f4-84ae-9ab311d5b812",
|
||||||
|
"name": "protocol3",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"access.token.claim": "false",
|
||||||
|
"claim.name": "protocol1_updated",
|
||||||
|
"full.path": "false",
|
||||||
|
"id.token.claim": "false",
|
||||||
|
"userinfo.token.claim": "false"
|
||||||
|
},
|
||||||
|
"consentRequired": "false",
|
||||||
|
"id": "bbf6390f-e95f-4c20-882b-9dad328363b9",
|
||||||
|
"name": "protocol1",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-group-membership-mapper"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
set_module_args(module_args)
|
||||||
|
|
||||||
|
# Run the module
|
||||||
|
|
||||||
|
with mock_good_connection():
|
||||||
|
with patch_keycloak_api(get_clientscope_by_name=return_value_get_clientscope_by_name,
|
||||||
|
get_clientscope_by_clientscopeid=return_value_get_clientscope_by_clientscopeid) \
|
||||||
|
as (mock_get_clientscope_by_name, mock_get_clientscope_by_clientscopeid, mock_create_clientscope,
|
||||||
|
mock_update_clientscope, mock_get_clientscope_protocolmapper_by_name,
|
||||||
|
mock_update_clientscope_protocolmappers,
|
||||||
|
mock_create_clientscope_protocolmapper, mock_delete_clientscope):
|
||||||
|
with self.assertRaises(AnsibleExitJson) as exec_info:
|
||||||
|
self.module.main()
|
||||||
|
|
||||||
|
# Verify number of call on each mock
|
||||||
|
self.assertEqual(mock_get_clientscope_by_name.call_count, 1)
|
||||||
|
self.assertEqual(mock_create_clientscope.call_count, 0)
|
||||||
|
self.assertEqual(mock_get_clientscope_by_clientscopeid.call_count, 1)
|
||||||
|
self.assertEqual(mock_update_clientscope.call_count, 1)
|
||||||
|
self.assertEqual(mock_get_clientscope_protocolmapper_by_name.call_count, 3)
|
||||||
|
self.assertEqual(mock_update_clientscope_protocolmappers.call_count, 3)
|
||||||
|
self.assertEqual(mock_create_clientscope_protocolmapper.call_count, 0)
|
||||||
|
self.assertEqual(mock_delete_clientscope.call_count, 0)
|
||||||
|
|
||||||
|
# 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()
|
Loading…
Reference in a new issue