1
0
Fork 0
mirror of https://github.com/ansible-collections/community.general.git synced 2024-09-14 20:13:21 +02:00

Add a module to set the keycloak client scope type (#6322)

The module keycloak_clientscope_type allows to set the client scope
types (optional/default) either on realm or client level.
This commit is contained in:
Simon Pahl 2023-04-22 22:55:46 +02:00 committed by GitHub
parent f4dd4d5ace
commit 1f2c7b1731
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 629 additions and 0 deletions

2
.github/BOTMETA.yml vendored
View file

@ -682,6 +682,8 @@ files:
maintainers: Gaetan2907
$modules/keycloak_clientscope.py:
maintainers: Gaetan2907
$modules/keycloak_clientscope_type.py:
maintainers: simonpahl
$modules/keycloak_clientsecret_info.py:
maintainers: fynncfchen johncant
$modules/keycloak_clientsecret_regenerate.py:

View file

@ -49,6 +49,16 @@ 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_DEFAULT_CLIENTSCOPES = "{url}/admin/realms/{realm}/default-default-client-scopes"
URL_DEFAULT_CLIENTSCOPE = "{url}/admin/realms/{realm}/default-default-client-scopes/{id}"
URL_OPTIONAL_CLIENTSCOPES = "{url}/admin/realms/{realm}/default-optional-client-scopes"
URL_OPTIONAL_CLIENTSCOPE = "{url}/admin/realms/{realm}/default-optional-client-scopes/{id}"
URL_CLIENT_DEFAULT_CLIENTSCOPES = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes"
URL_CLIENT_DEFAULT_CLIENTSCOPE = "{url}/admin/realms/{realm}/clients/{cid}/default-client-scopes/{id}"
URL_CLIENT_OPTIONAL_CLIENTSCOPES = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes"
URL_CLIENT_OPTIONAL_CLIENTSCOPE = "{url}/admin/realms/{realm}/clients/{cid}/optional-client-scopes/{id}"
URL_CLIENT_GROUP_ROLEMAPPINGS = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}"
URL_CLIENT_GROUP_ROLEMAPPINGS_AVAILABLE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/available"
URL_CLIENT_GROUP_ROLEMAPPINGS_COMPOSITE = "{url}/admin/realms/{realm}/groups/{id}/role-mappings/clients/{client}/composite"
@ -1163,6 +1173,131 @@ class KeycloakAPI(object):
self.module.fail_json(msg='Could not update protocolmappers for clientscope %s in realm %s: %s'
% (mapper_rep, realm, str(e)))
def get_default_clientscopes(self, realm, client_id=None):
"""Fetch the name and ID of all clientscopes on the Keycloak server.
To fetch the full data of the client scope, make a subsequent call to
get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
:return The default clientscopes of this realm or client
"""
url = URL_DEFAULT_CLIENTSCOPES if client_id is None else URL_CLIENT_DEFAULT_CLIENTSCOPES
return self._get_clientscopes_of_type(realm, url, 'default', client_id)
def get_optional_clientscopes(self, realm, client_id=None):
"""Fetch the name and ID of all clientscopes on the Keycloak server.
To fetch the full data of the client scope, make a subsequent call to
get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
:return The optinal clientscopes of this realm or client
"""
url = URL_OPTIONAL_CLIENTSCOPES if client_id is None else URL_CLIENT_OPTIONAL_CLIENTSCOPES
return self._get_clientscopes_of_type(realm, url, 'optional', client_id)
def _get_clientscopes_of_type(self, realm, url_template, scope_type, client_id=None):
"""Fetch the name and ID of all clientscopes on the Keycloak server.
To fetch the full data of the client scope, make a subsequent call to
get_clientscope_by_clientscopeid, passing in the ID of the client scope you wish to return.
:param realm: Realm in which the clientscope resides.
:param url_template the template for the right type
:param scope_type this can be either optinal or default
:param client_id: The client in which the clientscope resides.
:return The clientscopes of the specified type of this realm
"""
if client_id is None:
clientscopes_url = url_template.format(url=self.baseurl, realm=realm)
try:
return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders,
timeout=self.connection_timeout, validate_certs=self.validate_certs).read()))
except Exception as e:
self.module.fail_json(msg="Could not fetch list of %s clientscopes in realm %s: %s" % (scope_type, realm, str(e)))
else:
cid = self.get_client_id(client_id=client_id, realm=realm)
clientscopes_url = url_template.format(url=self.baseurl, realm=realm, cid=cid)
try:
return json.loads(to_native(open_url(clientscopes_url, method="GET", http_agent=self.http_agent, headers=self.restheaders,
timeout=self.connection_timeout, validate_certs=self.validate_certs).read()))
except Exception as e:
self.module.fail_json(msg="Could not fetch list of %s clientscopes in client %s: %s" % (scope_type, client_id, clientscopes_url))
def _decide_url_type_clientscope(self, client_id=None, scope_type="default"):
"""Decides which url to use.
:param scope_type this can be either optinal or default
:param client_id: The client in which the clientscope resides.
"""
if client_id is None:
if scope_type == "default":
return URL_DEFAULT_CLIENTSCOPE
if scope_type == "optional":
return URL_OPTIONAL_CLIENTSCOPE
else:
if scope_type == "default":
return URL_CLIENT_DEFAULT_CLIENTSCOPE
if scope_type == "optional":
return URL_CLIENT_OPTIONAL_CLIENTSCOPE
def add_default_clientscope(self, id, realm="master", client_id=None):
"""Add a client scope as default either on realm or client level.
:param id: Client scope Id.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
"""
self._action_type_clientscope(id, client_id, "default", realm, 'add')
def add_optional_clientscope(self, id, realm="master", client_id=None):
"""Add a client scope as optional either on realm or client level.
:param id: Client scope Id.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
"""
self._action_type_clientscope(id, client_id, "optional", realm, 'add')
def delete_default_clientscope(self, id, realm="master", client_id=None):
"""Remove a client scope as default either on realm or client level.
:param id: Client scope Id.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
"""
self._action_type_clientscope(id, client_id, "default", realm, 'delete')
def delete_optional_clientscope(self, id, realm="master", client_id=None):
"""Remove a client scope as optional either on realm or client level.
:param id: Client scope Id.
:param realm: Realm in which the clientscope resides.
:param client_id: The client in which the clientscope resides.
"""
self._action_type_clientscope(id, client_id, "optional", realm, 'delete')
def _action_type_clientscope(self, id=None, client_id=None, scope_type="default", realm="master", action='add'):
""" Delete or add a clientscope of type.
:param name: The name of the clientscope. A lookup will be performed to retrieve the clientscope ID.
:param client_id: The ID of the clientscope (preferred to name).
:param scope_type 'default' or 'optional'
:param realm: The realm in which this group resides, default "master".
"""
cid = None if client_id is None else self.get_client_id(client_id=client_id, realm=realm)
# should have a good cid by here.
clientscope_type_url = self._decide_url_type_clientscope(client_id, scope_type).format(realm=realm, id=id, cid=cid, url=self.baseurl)
try:
method = 'PUT' if action == "add" else 'DELETE'
return open_url(clientscope_type_url, method=method, http_agent=self.http_agent, headers=self.restheaders, timeout=self.connection_timeout,
validate_certs=self.validate_certs)
except Exception as e:
place = 'realm' if client_id is None else 'client ' + client_id
self.module.fail_json(msg="Unable to %s %s clientscope %s @ %s : %s" % (action, scope_type, id, place, str(e)))
def create_clientsecret(self, id, realm="master"):
""" Generate a new client secret by id

View file

@ -0,0 +1,285 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) Ansible project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = '''
---
module: keycloak_clientscope_type
short_description: Set the type of aclientscope in realm or client via Keycloak API
version_added: 6.6.0
description:
- This module allows you to set the type (optional, default) of clientscopes
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.
attributes:
check_mode:
support: full
diff_mode:
support: full
options:
realm:
type: str
description:
- The Keycloak realm.
default: 'master'
client_id:
description:
- The I(client_id) of the client. If not set the clientscop types are set as a default for the realm.
aliases:
- clientId
type: str
default_clientscopes:
description:
- Client scopes that should be of type default.
type: list
elements: str
optional_clientscopes:
description:
- Client scopes that should be of type optional.
type: list
elements: str
extends_documentation_fragment:
- community.general.keycloak
- community.general.attributes
author:
- Simon Pahl (@simonpahl)
'''
EXAMPLES = '''
- name: Set default client scopes on realm level
community.general.keycloak_clientsecret_info:
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
auth_realm: master
auth_username: USERNAME
auth_password: PASSWORD
realm: "MyCustomRealm"
default_clientscopes: ['profile', 'roles']
delegate_to: localhost
- name: Set default and optional client scopes on client level with token auth
community.general.keycloak_clientsecret_info:
auth_client_id: admin-cli
auth_keycloak_url: https://auth.example.com/auth
token: TOKEN
realm: "MyCustomRealm"
client_id: "MyCustomClient"
default_clientscopes: ['profile', 'roles']
optional_clientscopes: ['phone']
delegate_to: localhost
'''
RETURN = '''
msg:
description: Message as to what action was taken.
returned: always
type: str
sample: ""
proposed:
description: Representation of proposed client-scope types mapping.
returned: always
type: dict
sample: {
default_clientscopes: ["profile", "role"],
optional_clientscopes: []
}
existing:
description:
- Representation of client scopes before module execution.
returned: always
type: dict
sample: {
default_clientscopes: ["profile", "role"],
optional_clientscopes: ["phone"]
}
end_state:
description:
- Representation of client scopes after module execution.
- The sample is truncated.
returned: on success
type: dict
sample: {
default_clientscopes: ["profile", "role"],
optional_clientscopes: []
}
'''
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import (
KeycloakAPI, KeycloakError, get_token)
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import \
keycloak_argument_spec
def keycloak_clientscope_type_module():
"""
Returns an AnsibleModule definition.
:return: argument_spec dict
"""
argument_spec = keycloak_argument_spec()
meta_args = dict(
realm=dict(default='master'),
client_id=dict(type='str', aliases=['clientId']),
default_clientscopes=dict(type='list', elements='str'),
optional_clientscopes=dict(type='list', elements='str'),
)
argument_spec.update(meta_args)
module = AnsibleModule(
argument_spec=argument_spec,
supports_check_mode=True,
required_one_of=([
['token', 'auth_realm', 'auth_username', 'auth_password'],
['default_clientscopes', 'optional_clientscopes']
]),
required_together=([['auth_realm', 'auth_username', 'auth_password']]),
mutually_exclusive=[
['token', 'auth_realm'],
['token', 'auth_username'],
['token', 'auth_password']
])
return module
def clientscopes_to_add(existing, proposed):
to_add = []
existing_clientscope_ids = extract_field(existing, 'id')
for clientscope in proposed:
if not clientscope['id'] in existing_clientscope_ids:
to_add.append(clientscope)
return to_add
def clientscopes_to_delete(existing, proposed):
to_delete = []
proposed_clientscope_ids = extract_field(proposed, 'id')
for clientscope in existing:
if not clientscope['id'] in proposed_clientscope_ids:
to_delete.append(clientscope)
return to_delete
def extract_field(dictionary, field='name'):
return [cs[field] for cs in dictionary]
def main():
"""
Module keycloak_clientscope_type
:return:
"""
module = keycloak_clientscope_type_module()
# 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')
client_id = module.params.get('client_id')
default_clientscopes = module.params.get('default_clientscopes')
optional_clientscopes = module.params.get('optional_clientscopes')
result = dict(changed=False, msg='', proposed={}, existing={}, end_state={})
all_clientscopes = kc.get_clientscopes(realm)
default_clientscopes_real = []
optional_clientscopes_real = []
for client_scope in all_clientscopes:
if default_clientscopes is not None and client_scope["name"] in default_clientscopes:
default_clientscopes_real.append(client_scope)
if optional_clientscopes is not None and client_scope["name"] in optional_clientscopes:
optional_clientscopes_real.append(client_scope)
if default_clientscopes is not None and len(default_clientscopes_real) != len(default_clientscopes):
module.fail_json(msg='At least one of the default_clientscopes does not exist!')
if optional_clientscopes is not None and len(optional_clientscopes_real) != len(optional_clientscopes):
module.fail_json(msg='At least one of the optional_clientscopes does not exist!')
result['proposed'].update({
'default_clientscopes': 'no-change' if default_clientscopes is None else default_clientscopes,
'optional_clientscopes': 'no-change' if optional_clientscopes is None else optional_clientscopes
})
default_clientscopes_existing = kc.get_default_clientscopes(realm, client_id)
optional_clientscopes_existing = kc.get_optional_clientscopes(realm, client_id)
result['existing'].update({
'default_clientscopes': extract_field(default_clientscopes_existing),
'optional_clientscopes': extract_field(optional_clientscopes_existing)
})
if module._diff:
result['diff'] = dict(before=result['existing'], after=result['proposed'])
if module.check_mode:
module.exit_json(**result)
default_clientscopes_add = clientscopes_to_add(default_clientscopes_existing, default_clientscopes_real)
optional_clientscopes_add = clientscopes_to_add(optional_clientscopes_existing, optional_clientscopes_real)
default_clientscopes_delete = clientscopes_to_delete(default_clientscopes_existing, default_clientscopes_real)
optional_clientscopes_delete = clientscopes_to_delete(optional_clientscopes_existing, optional_clientscopes_real)
# first delete so clientscopes can change type
for clientscope in default_clientscopes_delete:
kc.delete_default_clientscope(clientscope['id'], realm, client_id)
for clientscope in optional_clientscopes_delete:
kc.delete_optional_clientscope(clientscope['id'], realm, client_id)
for clientscope in default_clientscopes_add:
kc.add_default_clientscope(clientscope['id'], realm, client_id)
for clientscope in optional_clientscopes_add:
kc.add_optional_clientscope(clientscope['id'], realm, client_id)
result["changed"] = (
len(default_clientscopes_add) > 0
or len(optional_clientscopes_add) > 0
or len(default_clientscopes_delete) > 0
or len(optional_clientscopes_delete) > 0
)
result['end_state'].update({
'default_clientscopes': extract_field(kc.get_default_clientscopes(realm, client_id)),
'optional_clientscopes': extract_field(kc.get_optional_clientscopes(realm, client_id))
})
module.exit_json(**result)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,16 @@
<!--
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
-->
The integration test can be performed as follows:
```
# 1. Start docker-compose:
docker-compose -f tests/integration/targets/keycloak_clientscope_type/docker-compose.yml down
docker-compose -f tests/integration/targets/keycloak_clientscope_type/docker-compose.yml up -d
# 2. Run the integration tests:
ansible-test integration keycloak_clientscope_type --allow-unsupported -v
```

View file

@ -0,0 +1,16 @@
---
# 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
version: '3.4'
services:
keycloak:
image: quay.io/keycloak/keycloak:21.0.2
ports:
- 8080:8080
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: password
command: start-dev

View file

@ -0,0 +1,164 @@
---
# 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
# Fixtures
- name: Create keycloak realm
community.general.keycloak_realm:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
id: ""
state: present
enabled: true
- name: Create keycloak client
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
enabled: true
- 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
### Tests
### Realm
- name: adjust client-scope types in realm
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
default_clientscopes: ['scope1', 'scope2']
optional_clientscopes: []
register: result
- name: Assert that client scope types are set
assert:
that:
- result is changed
- result.end_state != {}
- '"scope1" in result.end_state.default_clientscopes'
- '"scope2" in result.end_state.default_clientscopes'
- result.end_state.default_clientscopes|length == 2
- result.end_state.optional_clientscopes|length == 0
- name: adjust client-scope types in realm again
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
default_clientscopes: ['scope1', 'scope2']
optional_clientscopes: []
register: result
failed_when: result is changed
- name: adjust client-scope types in realm move scope 2 to optional
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
default_clientscopes: ['scope1']
optional_clientscopes: ['scope2']
register: result
- name: Assert that client scope types are set
assert:
that:
- result is changed
- result.end_state != {}
- '"scope1" in result.end_state.default_clientscopes'
- '"scope2" in result.end_state.optional_clientscopes'
- result.end_state.default_clientscopes|length == 1
- result.end_state.optional_clientscopes|length == 1
### Client
- name: adjust client-scope types in client
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
client_id: "{{ client_id }}"
default_clientscopes: ['scope1', 'scope2']
optional_clientscopes: []
register: result
- name: Assert that client scope types are set
assert:
that:
- result is changed
- result.end_state != {}
- '"scope1" in result.end_state.default_clientscopes'
- '"scope2" in result.end_state.default_clientscopes'
- result.end_state.default_clientscopes|length == 2
- result.end_state.optional_clientscopes|length == 0
- name: adjust client-scope types in client again
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
client_id: "{{ client_id }}"
default_clientscopes: ['scope1', 'scope2']
optional_clientscopes: []
register: result
failed_when: result is changed
- name: adjust client-scope types in client move scope 2 to optional
community.general.keycloak_clientscope_type:
auth_keycloak_url: "{{ url }}"
auth_realm: "{{ admin_realm }}"
auth_username: "{{ admin_user }}"
auth_password: "{{ admin_password }}"
realm: "{{ realm }}"
client_id: "{{ client_id }}"
default_clientscopes: ['scope1']
optional_clientscopes: ['scope2']
register: result
- name: Assert that client scope types are set
assert:
that:
- result is changed
- result.end_state != {}
- '"scope1" in result.end_state.default_clientscopes'
- '"scope2" in result.end_state.optional_clientscopes'
- result.end_state.default_clientscopes|length == 1
- result.end_state.optional_clientscopes|length == 1

View file

@ -0,0 +1,11 @@
---
# 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
url: http://localhost:8080
admin_realm: master
admin_user: admin
admin_password: password
realm: clientscope-type-realm
client_id: clientscope-type-client