mirror of
https://github.com/ansible-collections/community.general.git
synced 2025-10-22 20:13:59 -07:00
Keycloak client scope support (#10842)
Some checks failed
EOL CI / EOL Sanity (Ⓐ2.16) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py2.7) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py3.11) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py3.6) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/3/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/3/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/3/) (push) Has been cancelled
nox / Run extra sanity tests (push) Has been cancelled
Some checks failed
EOL CI / EOL Sanity (Ⓐ2.16) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py2.7) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py3.11) (push) Has been cancelled
EOL CI / EOL Units (Ⓐ2.16+py3.6) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+alpine3+py:azp/posix/3/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+fedora38+py:azp/posix/3/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/1/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/2/) (push) Has been cancelled
EOL CI / EOL I (Ⓐ2.16+opensuse15+py:azp/posix/3/) (push) Has been cancelled
nox / Run extra sanity tests (push) Has been cancelled
* first commit * sanity * fixe test * trailing white space * sanity * Fragment * test sanity * Update changelogs/fragments/10842-keycloak-client-scope-support.yml Co-authored-by: Felix Fontein <felix@fontein.de> * Update plugins/modules/keycloak_client.py Co-authored-by: Felix Fontein <felix@fontein.de> * add client_scopes_behavior * Sanity * Sanity * Update plugins/modules/keycloak_client.py Co-authored-by: Felix Fontein <felix@fontein.de> * Fix typo. Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_client.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_client.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_client.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> * Update plugins/modules/keycloak_client.py Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --------- Co-authored-by: Andre Desrosiers <andre.desrosiers@ssss.gouv.qc.ca> Co-authored-by: Felix Fontein <felix@fontein.de> Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com>
This commit is contained in:
parent
30894f4144
commit
f34842b7b2
5 changed files with 602 additions and 15 deletions
|
@ -0,0 +1,2 @@
|
|||
minor_changes:
|
||||
- keycloak_client - add idempotent support for ``optional_client_scopes`` and ``optional_client_scopes``, and ensure consistent change detection between check mode and live run (https://github.com/ansible-collections/community.general/issues/5495, https://github.com/ansible-collections/community.general/pull/10842).
|
|
@ -359,9 +359,23 @@ options:
|
|||
- authenticationFlowBindingOverrides
|
||||
version_added: 3.4.0
|
||||
|
||||
client_scopes_behavior:
|
||||
description:
|
||||
- Determine how O(default_client_scopes) and O(optional_client_scopes) behave when updating an existing client.
|
||||
- 'V(ignore): Do not change the client scopes of an existing client. This is the default for backward compatibility.'
|
||||
- 'V(patch): Add missing scopes, do not remove any missing scopes.'
|
||||
- 'V(idempotent): Make the client scopes exactly as specified, adding and removing scopes as needed.'
|
||||
aliases:
|
||||
- clientScopesBehavior
|
||||
type: str
|
||||
choices: ['ignore', 'patch', 'idempotent']
|
||||
default: 'ignore'
|
||||
version_added: 11.4.0
|
||||
|
||||
default_client_scopes:
|
||||
description:
|
||||
- List of default client scopes.
|
||||
- See O(client_scopes_behavior) for how this behaves when updating an existing client.
|
||||
aliases:
|
||||
- defaultClientScopes
|
||||
type: list
|
||||
|
@ -371,6 +385,7 @@ options:
|
|||
optional_client_scopes:
|
||||
description:
|
||||
- List of optional client scopes.
|
||||
- See O(client_scopes_behavior) for how this behaves when updating an existing client.
|
||||
aliases:
|
||||
- optionalClientScopes
|
||||
type: list
|
||||
|
@ -743,6 +758,80 @@ PROTOCOL_DOCKER_V2 = 'docker-v2'
|
|||
CLIENT_META_DATA = ['authorizationServicesEnabled']
|
||||
|
||||
|
||||
def normalise_scopes_for_behavior(desired_client, before_client, clientScopesBehavior):
|
||||
"""
|
||||
Normalize the desired and existing client scopes according to the specified behavior.
|
||||
|
||||
This function adjusts the lists of default and optional client scopes in the desired client
|
||||
configuration based on the selected behavior:
|
||||
- 'ignore': The desired scopes are set to match the existing scopes.
|
||||
- 'patch': Any scopes present in the existing configuration but missing from the desired configuration
|
||||
are appended to the desired scopes.
|
||||
- 'idempotent': No modification is made; the desired scopes are used as-is.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including default and optional client scopes.
|
||||
|
||||
:param before_client:
|
||||
type: dict
|
||||
description: The current client configuration, including default and optional client scopes.
|
||||
|
||||
:param clientScopesBehavior:
|
||||
type: str
|
||||
description: The behavior mode for handling client scopes. Must be one of 'ignore', 'patch', or 'idempotent'.
|
||||
|
||||
:return:
|
||||
type: tuple
|
||||
description: Returns a tuple of (desired_client, before_client) after normalization.
|
||||
"""
|
||||
desired_client = copy.deepcopy(desired_client)
|
||||
before_client = copy.deepcopy(before_client)
|
||||
if clientScopesBehavior == 'ignore':
|
||||
desired_client['defaultClientScopes'] = copy.deepcopy(before_client['defaultClientScopes'])
|
||||
desired_client['optionalClientScopes'] = copy.deepcopy(before_client['optionalClientScopes'])
|
||||
elif clientScopesBehavior == 'patch':
|
||||
for scope in before_client['defaultClientScopes']:
|
||||
if scope not in desired_client['defaultClientScopes']:
|
||||
desired_client['defaultClientScopes'].append(scope)
|
||||
for scope in before_client['optionalClientScopes']:
|
||||
if scope not in desired_client['optionalClientScopes']:
|
||||
desired_client['optionalClientScopes'].append(scope)
|
||||
|
||||
return desired_client, before_client
|
||||
|
||||
|
||||
def check_optional_scopes_not_default(desired_client, clientScopesBehavior, module):
|
||||
"""
|
||||
Ensure that no client scope is assigned as both default and optional.
|
||||
|
||||
This function checks the desired client configuration to verify that no scope is present
|
||||
in both the default and optional client scopes. If such a conflict is found, the module
|
||||
execution fails with an appropriate error message.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including default and optional client scopes.
|
||||
|
||||
:param clientScopesBehavior:
|
||||
type: str
|
||||
description: The behavior mode for handling client scopes. Must be one of 'ignore', 'patch', or 'idempotent'.
|
||||
|
||||
:param module:
|
||||
type: AnsibleModule
|
||||
description: The Ansible module instance, used to fail execution if a conflict is detected.
|
||||
|
||||
:return:
|
||||
type: None
|
||||
description: Returns None. Fails the module if a scope is both default and optional.
|
||||
"""
|
||||
if clientScopesBehavior == 'ignore':
|
||||
return
|
||||
for scope in desired_client['optionalClientScopes']:
|
||||
if scope in desired_client['defaultClientScopes']:
|
||||
module.fail_json(msg='Client scope %s cannot be both default and optional' % scope)
|
||||
|
||||
|
||||
def normalise_cr(clientrep, remove_ids=False):
|
||||
""" Re-sorts any properties where the order so that diff's is minimised, and adds default values where appropriate so that the
|
||||
the change detection is more effective.
|
||||
|
@ -753,16 +842,25 @@ def normalise_cr(clientrep, remove_ids=False):
|
|||
:return: normalised clientrep dict
|
||||
"""
|
||||
# Avoid the dict passed in to be modified
|
||||
clientrep = clientrep.copy()
|
||||
clientrep = copy.deepcopy(clientrep)
|
||||
|
||||
if remove_ids:
|
||||
clientrep.pop('id', None)
|
||||
|
||||
if 'defaultClientScopes' in clientrep:
|
||||
clientrep['defaultClientScopes'] = list(sorted(clientrep['defaultClientScopes']))
|
||||
else:
|
||||
clientrep['defaultClientScopes'] = []
|
||||
|
||||
if 'optionalClientScopes' in clientrep:
|
||||
clientrep['optionalClientScopes'] = list(sorted(clientrep['optionalClientScopes']))
|
||||
else:
|
||||
clientrep['optionalClientScopes'] = []
|
||||
|
||||
if 'redirectUris' in clientrep:
|
||||
clientrep['redirectUris'] = list(sorted(clientrep['redirectUris']))
|
||||
else:
|
||||
clientrep['redirectUris'] = []
|
||||
|
||||
if 'protocolMappers' in clientrep:
|
||||
clientrep['protocolMappers'] = sorted(clientrep['protocolMappers'], key=lambda x: (x.get('name'), x.get('protocol'), x.get('protocolMapper')))
|
||||
|
@ -778,12 +876,27 @@ def normalise_cr(clientrep, remove_ids=False):
|
|||
|
||||
# Set to a default value.
|
||||
mapper['consentRequired'] = mapper.get('consentRequired', False)
|
||||
else:
|
||||
clientrep['protocolMappers'] = []
|
||||
|
||||
if 'attributes' in clientrep:
|
||||
for key, value in clientrep['attributes'].items():
|
||||
if isinstance(value, bool):
|
||||
clientrep['attributes'][key] = str(value).lower()
|
||||
clientrep['attributes'].pop('client.secret.creation.time', None)
|
||||
else:
|
||||
clientrep['attributes'] = []
|
||||
|
||||
if 'webOrigins' in clientrep:
|
||||
clientrep['webOrigins'] = sorted(clientrep['webOrigins'])
|
||||
else:
|
||||
clientrep['webOrigins'] = []
|
||||
|
||||
if 'redirectUris' in clientrep:
|
||||
clientrep['redirectUris'] = sorted(clientrep['redirectUris'])
|
||||
else:
|
||||
clientrep['redirectUris'] = []
|
||||
|
||||
return clientrep
|
||||
|
||||
|
||||
|
@ -871,6 +984,197 @@ def flow_binding_from_dict_to_model(newClientFlowBinding, realm, kc):
|
|||
return modelFlow
|
||||
|
||||
|
||||
def find_match(iterable, attribute, name):
|
||||
"""
|
||||
Search for an element in a list of dictionaries based on a given attribute and value.
|
||||
|
||||
This function iterates over the elements of an iterable (typically a list of dictionaries)
|
||||
and returns the first element whose value for the specified attribute matches `name`.
|
||||
|
||||
:param iterable:
|
||||
type: iterable (commonly list[dict])
|
||||
description: The collection of elements to search within (usually a list of dictionaries).
|
||||
|
||||
:param attribute:
|
||||
type: str
|
||||
description: The dictionary key/attribute used for comparison.
|
||||
|
||||
:param name:
|
||||
type: Any
|
||||
description: The value to search for within the given attribute.
|
||||
|
||||
:return:
|
||||
type: dict | None
|
||||
description: Returns the first dictionary where the attribute matches the given value case insensitive.
|
||||
Returns `None` if no match is found.
|
||||
"""
|
||||
name_lower = str(name).lower()
|
||||
return next(
|
||||
(
|
||||
value
|
||||
for value in iterable
|
||||
if attribute in value and str(value[attribute]).lower() == name_lower
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def add_default_client_scopes(desired_client, before_client, realm, kc):
|
||||
"""
|
||||
Adds missing default client scopes to a Keycloak client.
|
||||
|
||||
This function compares the desired default client scopes specified in `desired_client`
|
||||
with the current default client scopes in `before_client`. For each scope that is present
|
||||
in `desired_client["defaultClientScopes"]` but missing from `before_client['defaultClientScopes']`,
|
||||
it retrieves the scope information from Keycloak and adds it to the client.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including the list of default client scopes.
|
||||
|
||||
:param before_client:
|
||||
type: dict
|
||||
description: The current client configuration, including the list of default client scopes.
|
||||
|
||||
:param realm
|
||||
type: str
|
||||
description: The name of the Keycloak realm.
|
||||
|
||||
:param kc
|
||||
type: KeycloakAPI
|
||||
description: An instance of the Keycloak API client.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
desired_default_scope = desired_client["defaultClientScopes"]
|
||||
missing_scopes = [item for item in desired_default_scope if item not in before_client['defaultClientScopes']]
|
||||
if not missing_scopes:
|
||||
return
|
||||
client_scopes = kc.get_clientscopes(realm)
|
||||
for name in missing_scopes:
|
||||
scope = find_match(client_scopes, "name", name)
|
||||
if scope:
|
||||
kc.add_default_clientscope(scope['id'], realm, desired_client['clientId'])
|
||||
|
||||
|
||||
def add_optional_client_scopes(desired_client, before_client, realm, kc):
|
||||
"""
|
||||
Adds missing optional client scopes to a Keycloak client.
|
||||
|
||||
This function compares the desired optional client scopes specified in `desired_client`
|
||||
with the current optional client scopes in `before_client`. For each scope that is present
|
||||
in `desired_client["optionalClientScopes"]` but missing from `before_client['optionalClientScopes']`,
|
||||
it retrieves the scope information from Keycloak and adds it to the client.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including the list of optional client scopes.
|
||||
|
||||
:param before_client:
|
||||
type: dict
|
||||
description: The current client configuration, including the list of optional client scopes.
|
||||
|
||||
:param realm:
|
||||
type: str
|
||||
description: The name of the Keycloak realm.
|
||||
|
||||
:param kc:
|
||||
type: KeycloakAPI
|
||||
description: An instance of the Keycloak API client.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
desired_optional_scope = desired_client["optionalClientScopes"]
|
||||
missing_scopes = [item for item in desired_optional_scope if item not in before_client['optionalClientScopes']]
|
||||
if not missing_scopes:
|
||||
return
|
||||
client_scopes = kc.get_clientscopes(realm)
|
||||
for name in missing_scopes:
|
||||
scope = find_match(client_scopes, "name", name)
|
||||
if scope:
|
||||
kc.add_optional_clientscope(scope['id'], realm, desired_client['clientId'])
|
||||
|
||||
|
||||
def remove_default_client_scopes(desired_client, before_client, realm, kc):
|
||||
"""
|
||||
Removes default client scopes from a Keycloak client that are no longer desired.
|
||||
|
||||
This function compares the current default client scopes in `before_client`
|
||||
with the desired default client scopes in `desired_client`. For each scope that is present
|
||||
in `before_client["defaultClientScopes"]` but missing from `desired_client['defaultClientScopes']`,
|
||||
it retrieves the scope information from Keycloak and removes it from the client.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including the list of default client scopes.
|
||||
|
||||
:param before_client:
|
||||
type: dict
|
||||
description: The current client configuration, including the list of default client scopes.
|
||||
|
||||
:param realm:
|
||||
type: str
|
||||
description: The name of the Keycloak realm.
|
||||
|
||||
:param kc:
|
||||
type: KeycloakAPI
|
||||
description: An instance of the Keycloak API client.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
before_default_scope = before_client["defaultClientScopes"]
|
||||
missing_scopes = [item for item in before_default_scope if item not in desired_client['defaultClientScopes']]
|
||||
if not missing_scopes:
|
||||
return
|
||||
client_scopes = kc.get_default_clientscopes(realm, desired_client['clientId'])
|
||||
for name in missing_scopes:
|
||||
scope = find_match(client_scopes, "name", name)
|
||||
if scope:
|
||||
kc.delete_default_clientscope(scope['id'], realm, desired_client['clientId'])
|
||||
|
||||
|
||||
def remove_optional_client_scopes(desired_client, before_client, realm, kc):
|
||||
"""
|
||||
Removes optional client scopes from a Keycloak client that are no longer desired.
|
||||
|
||||
This function compares the current optional client scopes in `before_client`
|
||||
with the desired optional client scopes in `desired_client`. For each scope that is present
|
||||
in `before_client["optionalClientScopes"]` but missing from `desired_client['optionalClientScopes']`,
|
||||
it retrieves the scope information from Keycloak and removes it from the client.
|
||||
|
||||
:param desired_client:
|
||||
type: dict
|
||||
description: The desired client configuration, including the list of optional client scopes.
|
||||
|
||||
:param before_client:
|
||||
type: dict
|
||||
description: The current client configuration, including the list of optional client scopes.
|
||||
|
||||
:param realm:
|
||||
type: str
|
||||
description: The name of the Keycloak realm.
|
||||
|
||||
:param kc:
|
||||
type: KeycloakAPI
|
||||
description: An instance of the Keycloak API client.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
before_optional_scope = before_client["optionalClientScopes"]
|
||||
missing_scopes = [item for item in before_optional_scope if item not in desired_client['optionalClientScopes']]
|
||||
if not missing_scopes:
|
||||
return
|
||||
client_scopes = kc.get_optional_clientscopes(realm, desired_client['clientId'])
|
||||
for name in missing_scopes:
|
||||
scope = find_match(client_scopes, "name", name)
|
||||
if scope:
|
||||
kc.delete_optional_clientscope(scope['id'], realm, desired_client['clientId'])
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Module execution
|
||||
|
@ -944,6 +1248,7 @@ def main():
|
|||
),
|
||||
protocol_mappers=dict(type='list', elements='dict', options=protmapper_spec, aliases=['protocolMappers']),
|
||||
authorization_settings=dict(type='dict', aliases=['authorizationSettings']),
|
||||
client_scopes_behavior=dict(type='str', aliases=['clientScopesBehavior'], choices=['ignore', 'patch', 'idempotent'], default='ignore'),
|
||||
default_client_scopes=dict(type='list', elements='str', aliases=['defaultClientScopes']),
|
||||
optional_client_scopes=dict(type='list', elements='str', aliases=['optionalClientScopes']),
|
||||
)
|
||||
|
@ -970,6 +1275,7 @@ def main():
|
|||
|
||||
realm = module.params.get('realm')
|
||||
cid = module.params.get('id')
|
||||
clientScopesBehavior = module.params.get('client_scopes_behavior')
|
||||
state = module.params.get('state')
|
||||
|
||||
# Filter and map the parameters names that apply to the client
|
||||
|
@ -1002,11 +1308,17 @@ def main():
|
|||
new_param_value = [{k: v for k, v in x.items() if v is not None} for x in new_param_value]
|
||||
elif client_param == 'authentication_flow_binding_overrides':
|
||||
new_param_value = flow_binding_from_dict_to_model(new_param_value, realm, kc)
|
||||
elif client_param == 'attributes' and 'attributes' in before_client:
|
||||
attributes_copy = copy.deepcopy(before_client['attributes'])
|
||||
attributes_copy.update(new_param_value)
|
||||
new_param_value = attributes_copy
|
||||
elif client_param in ['clientScopesBehavior', 'client_scopes_behavior']:
|
||||
continue
|
||||
|
||||
changeset[camel(client_param)] = new_param_value
|
||||
|
||||
# Prepare the desired values using the existing values (non-existence results in a dict that is save to use as a basis)
|
||||
desired_client = before_client.copy()
|
||||
desired_client = copy.deepcopy(before_client)
|
||||
desired_client.update(changeset)
|
||||
|
||||
result['proposed'] = sanitize_cr(changeset)
|
||||
|
@ -1048,28 +1360,39 @@ def main():
|
|||
|
||||
else:
|
||||
if state == 'present':
|
||||
# We can only compare the current client with the proposed updates we have
|
||||
desired_client_with_scopes, before_client_with_scopes = normalise_scopes_for_behavior(desired_client, before_client, clientScopesBehavior)
|
||||
check_optional_scopes_not_default(desired_client, clientScopesBehavior, module)
|
||||
before_norm = normalise_cr(before_client_with_scopes, remove_ids=True)
|
||||
desired_norm = normalise_cr(desired_client_with_scopes, remove_ids=True)
|
||||
# no changes
|
||||
if before_norm == desired_norm:
|
||||
result['changed'] = False
|
||||
result['end_state'] = sanitize_cr(before_client)
|
||||
result['msg'] = 'No changes required for Client %s.' % desired_client['clientId']
|
||||
module.exit_json(**result)
|
||||
|
||||
# Process an update
|
||||
result['changed'] = True
|
||||
|
||||
if module.check_mode:
|
||||
# We can only compare the current client with the proposed updates we have
|
||||
before_norm = normalise_cr(before_client, remove_ids=True)
|
||||
desired_norm = normalise_cr(desired_client, remove_ids=True)
|
||||
result['end_state'] = sanitize_cr(desired_client_with_scopes)
|
||||
if module._diff:
|
||||
result['diff'] = dict(before=sanitize_cr(before_norm),
|
||||
after=sanitize_cr(desired_norm))
|
||||
result['changed'] = desired_norm != before_norm
|
||||
|
||||
result['diff'] = dict(before=sanitize_cr(before_client),
|
||||
after=sanitize_cr(desired_client))
|
||||
module.exit_json(**result)
|
||||
|
||||
# do the update
|
||||
kc.update_client(cid, desired_client, realm=realm)
|
||||
|
||||
remove_default_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc)
|
||||
remove_optional_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc)
|
||||
add_default_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc)
|
||||
add_optional_client_scopes(desired_client_with_scopes, before_client_with_scopes, realm, kc)
|
||||
|
||||
after_client = kc.get_client_by_id(cid, realm=realm)
|
||||
normalize_kc_resp(after_client)
|
||||
|
||||
if before_client == after_client:
|
||||
result['changed'] = False
|
||||
if module._diff:
|
||||
result['diff'] = dict(before=sanitize_cr(before_client),
|
||||
after=sanitize_cr(after_client))
|
||||
|
|
|
@ -12,7 +12,7 @@ To run Keycloak client module's integration test, start a keycloak server using
|
|||
|
||||
Run the integration tests:
|
||||
|
||||
ansible-test integration -v keycloak_client --allow-unsupported --docker fedora35 --docker-network host
|
||||
ansible-test integration -v keycloak_client --allow-unsupported --docker --docker-network host
|
||||
|
||||
Cleanup:
|
||||
|
||||
|
|
|
@ -2,6 +2,13 @@
|
|||
# Copyright (c) Ansible Project
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
- name: Install required packages
|
||||
pip:
|
||||
name:
|
||||
- jmespath
|
||||
register: result
|
||||
until: result is success
|
||||
|
||||
- name: Wait for Keycloak
|
||||
uri:
|
||||
url: "{{ url }}/admin/"
|
||||
|
@ -71,7 +78,7 @@
|
|||
state: present
|
||||
redirect_uris: '{{redirect_uris1}}'
|
||||
attributes: '{{client_attributes1}}'
|
||||
protocol_mappers: '{{protocol_mappers1}}'
|
||||
protocol_mappers: '{{protocol_mappers2_unordered}}'
|
||||
authorization_services_enabled: false
|
||||
check_mode: true
|
||||
register: check_client_when_present_and_same
|
||||
|
@ -104,6 +111,37 @@
|
|||
that:
|
||||
- check_client_when_present_and_changed is changed
|
||||
|
||||
- name: Check client with modified protocol_mappers idempotence
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_id: "{{ client_id }}"
|
||||
state: present
|
||||
redirect_uris: '{{redirect_uris1}}'
|
||||
attributes: '{{client_attributes1}}'
|
||||
protocol_mappers: '{{protocol_mappers3_modifed}}'
|
||||
authorization_services_enabled: false
|
||||
service_accounts_enabled: true
|
||||
register: check_client_protocol_mappers_idempotence
|
||||
|
||||
- name: Assert idempotence changes to protocol_mappers
|
||||
assert:
|
||||
that:
|
||||
- check_client_protocol_mappers_idempotence is changed
|
||||
- end_state.protocolMappers | length == 3
|
||||
- end_state.protocolMappers | community.general.json_query("[?name == 'email_verified']") | length == 0
|
||||
- end_state.protocolMappers | community.general.json_query("[?name == 'address']") | length == 1
|
||||
- end_state.protocolMappers | community.general.json_query("[?name == 'email']") | length == 1
|
||||
- end_state.protocolMappers | community.general.json_query("[?name == 'family_name']") | length == 1
|
||||
- email.config is defined
|
||||
- email.config['access.token.claim'] == "false"
|
||||
vars:
|
||||
end_state: "{{ check_client_protocol_mappers_idempotence.end_state }}"
|
||||
email: "{{ end_state.protocolMappers | community.general.json_query('[?name == `email`]') | first | d({}) }}"
|
||||
|
||||
- name: Desire client with flow binding overrides
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
|
@ -231,3 +269,191 @@
|
|||
- "'authenticationFlowBindingOverrides' in desire_client_with_flow_binding_overrides.end_state"
|
||||
- "'browser' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides"
|
||||
- "'direct_grant' not in desire_client_with_flow_binding_overrides.end_state.authenticationFlowBindingOverrides"
|
||||
|
||||
- name: Create a scope1 client scope
|
||||
community.general.keycloak_clientscope:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
name: scope1
|
||||
description: "test 1"
|
||||
protocol: openid-connect
|
||||
|
||||
- name: Create a scope2 client scope
|
||||
community.general.keycloak_clientscope:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
name: scope2
|
||||
description: "test 2"
|
||||
protocol: openid-connect
|
||||
|
||||
- name: Create a scope3 client scope
|
||||
community.general.keycloak_clientscope:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
name: scope3
|
||||
description: "test 3"
|
||||
protocol: openid-connect
|
||||
|
||||
- name: Create Keycloak client with default_client_scopes (idempotent behavior)
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_scopes_behavior: idempotent
|
||||
default_client_scopes: ['scope1']
|
||||
optional_client_scopes: ['scope2']
|
||||
client_id: testSD-bug
|
||||
state: present
|
||||
register: desire_client_with_default_client_scopes
|
||||
|
||||
- name: Assert default_client_scopes and optional_client_scopes are set correctly
|
||||
assert:
|
||||
that:
|
||||
- desire_client_with_default_client_scopes is changed
|
||||
- '"scope1" in end_state.defaultClientScopes'
|
||||
- '"scope2" in end_state.optionalClientScopes'
|
||||
- end_state.defaultClientScopes | length == 1
|
||||
- end_state.optionalClientScopes | length == 1
|
||||
vars:
|
||||
end_state: "{{ desire_client_with_default_client_scopes.end_state }}"
|
||||
|
||||
- name: Update Keycloak client with new scopes (ignore behavior, check mode)
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
default_client_scopes: ['scope3']
|
||||
optional_client_scopes: ['scope3']
|
||||
client_id: testSD-bug
|
||||
state: present
|
||||
check_mode: true
|
||||
register: desire_client_with_default_client_scopes
|
||||
|
||||
- name: Assert client scopes remain unchanged with ignore behavior
|
||||
assert:
|
||||
that:
|
||||
- desire_client_with_default_client_scopes is not changed
|
||||
- end_state.defaultClientScopes | length == 1
|
||||
- end_state.optionalClientScopes | length == 1
|
||||
- '"scope1" in end_state.defaultClientScopes'
|
||||
- '"scope2" in end_state.optionalClientScopes'
|
||||
vars:
|
||||
end_state: "{{ desire_client_with_default_client_scopes.end_state }}"
|
||||
|
||||
- name: Update Keycloak client with conflicting scopes (patch behavior, should fail)
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_scopes_behavior: patch
|
||||
default_client_scopes: ['scope3']
|
||||
optional_client_scopes: ['scope1', 'scope2', 'scope3']
|
||||
client_id: testSD-bug
|
||||
state: present
|
||||
ignore_errors: true
|
||||
register: desire_client_with_default_client_scopes
|
||||
|
||||
- name: Assert patch behavior fails when scope is both default and optional
|
||||
assert:
|
||||
that:
|
||||
- desire_client_with_default_client_scopes is failed
|
||||
- "'scope3' in desire_client_with_default_client_scopes.msg"
|
||||
|
||||
- name: Update Keycloak client with new scopes (patch behavior)
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_scopes_behavior: patch
|
||||
default_client_scopes: ['scope1', 'scope3']
|
||||
optional_client_scopes: []
|
||||
client_id: testSD-bug
|
||||
state: present
|
||||
register: desire_client_with_default_client_scopes
|
||||
|
||||
- name: Assert client scopes are patched correctly
|
||||
assert:
|
||||
that:
|
||||
- desire_client_with_default_client_scopes is changed
|
||||
- end_state.defaultClientScopes | length == 2
|
||||
- end_state.optionalClientScopes | length == 1
|
||||
- '"scope1" in end_state.defaultClientScopes'
|
||||
- '"scope3" in end_state.defaultClientScopes'
|
||||
- '"scope2" in end_state.optionalClientScopes'
|
||||
vars:
|
||||
end_state: "{{ desire_client_with_default_client_scopes.end_state }}"
|
||||
|
||||
- name: Update Keycloak client with empty default_client_scopes (idempotent behavior)
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_scopes_behavior: idempotent
|
||||
default_client_scopes: []
|
||||
optional_client_scopes: ['scope3']
|
||||
client_id: testSD-bug
|
||||
state: present
|
||||
register: desire_client_with_default_client_scopes
|
||||
|
||||
- name: Assert idempotent behavior with empty default_client_scopes
|
||||
assert:
|
||||
that:
|
||||
- desire_client_with_default_client_scopes is changed
|
||||
- end_state.defaultClientScopes | length == 0
|
||||
- end_state.optionalClientScopes | length == 1
|
||||
- '"scope3" in end_state.optionalClientScopes'
|
||||
vars:
|
||||
end_state: "{{ desire_client_with_default_client_scopes.end_state }}"
|
||||
|
||||
- name: Create client with initial attributes
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_id: "{{ client_id_2 }}"
|
||||
state: present
|
||||
attributes: '{{ client_attributes1 }}'
|
||||
register: check_client_when_present_and_attributes_modified
|
||||
|
||||
- name: Update client attributes
|
||||
community.general.keycloak_client:
|
||||
auth_keycloak_url: "{{ url }}"
|
||||
auth_realm: "{{ admin_realm }}"
|
||||
auth_username: "{{ admin_user }}"
|
||||
auth_password: "{{ admin_password }}"
|
||||
realm: "{{ realm }}"
|
||||
client_id: "{{ client_id_2 }}"
|
||||
state: present
|
||||
attributes: '{{ client_attributes2 }}'
|
||||
register: check_client_when_present_and_attributes_modified
|
||||
|
||||
- name: Assert client attributes are updated
|
||||
assert:
|
||||
that:
|
||||
- check_client_when_present_and_attributes_modified is changed
|
||||
- end_state.attributes["backchannel.logout.revoke.offline.tokens"] == 'false'
|
||||
- end_state.attributes["backchannel.logout.session.required"] == 'false'
|
||||
- end_state.attributes["oauth2.device.authorization.grant.enabled"] == 'false'
|
||||
vars:
|
||||
end_state: "{{ check_client_when_present_and_attributes_modified.end_state }}"
|
|
@ -9,6 +9,7 @@ admin_user: admin
|
|||
admin_password: password
|
||||
realm: myrealm
|
||||
client_id: myclient
|
||||
client_id_2: mynewclient
|
||||
role: myrole
|
||||
description_1: desc 1
|
||||
description_2: desc 2
|
||||
|
@ -24,7 +25,9 @@ redirect_uris1:
|
|||
- "http://example.b.com/"
|
||||
- "http://example.a.com/"
|
||||
|
||||
client_attributes1: {"backchannel.logout.session.required": true, "backchannel.logout.revoke.offline.tokens": false, "client.secret.creation.time": 0}
|
||||
client_attributes1: {"backchannel.logout.session.required": true, "backchannel.logout.revoke.offline.tokens": false, "oauth2.device.authorization.grant.enabled": true, "client.secret.creation.time": 0}
|
||||
|
||||
client_attributes2: {"backchannel.logout.session.required": false, "oauth2.device.authorization.grant.enabled": false, "client.secret.creation.time": 0}
|
||||
|
||||
protocol_mappers1:
|
||||
- name: 'email'
|
||||
|
@ -59,3 +62,36 @@ protocol_mappers1:
|
|||
"id.token.claim": "true"
|
||||
"access.token.claim": "true"
|
||||
"userinfo.token.claim": "true"
|
||||
|
||||
protocol_mappers2_unordered:
|
||||
- "{{ protocol_mappers1[2] }}"
|
||||
- "{{ protocol_mappers1[1] }}"
|
||||
- "{{ protocol_mappers1[0] }}"
|
||||
|
||||
protocol_mappers3_modifed:
|
||||
- "{{ protocol_mappers1[2] }}"
|
||||
- name: address
|
||||
protocol: openid-connect
|
||||
protocolMapper: oidc-address-mapper
|
||||
consentRequired: false
|
||||
config:
|
||||
user.attribute.formatted: formatted
|
||||
user.attribute.country: country
|
||||
introspection.token.claim: 'true'
|
||||
user.attribute.postal_code: postal_code
|
||||
userinfo.token.claim: 'true'
|
||||
user.attribute.street: street
|
||||
id.token.claim: 'true'
|
||||
user.attribute.region: region
|
||||
access.token.claim: 'true'
|
||||
user.attribute.locality: locality
|
||||
- name: 'email'
|
||||
protocol: 'openid-connect'
|
||||
protocolMapper: 'oidc-usermodel-property-mapper'
|
||||
config:
|
||||
"claim.name": "email"
|
||||
"user.attribute": "email"
|
||||
"jsonType.label": "String"
|
||||
"id.token.claim": true
|
||||
"access.token.claim": false
|
||||
"userinfo.token.claim": true
|
Loading…
Add table
Add a link
Reference in a new issue