-
Notifications
You must be signed in to change notification settings - Fork 1.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
add plugin for generic keycloak component #8826
Changes from 10 commits
5eb678b
8c168cb
390e395
94d6b6e
3875b95
dfc07be
a4f9e90
027c932
2982a86
b2efc0d
a896778
8841140
aa4ba79
2ce50b0
9656f5b
c05071d
eb789a6
d163b12
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
minor_changes: | ||
- keycloak_component - add plugin for managing any keycloak component (https://github.com/ansible-collections/community.general/pull/8826) | ||
fivetide marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change | ||||||||
---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,330 @@ | ||||||||||
#!/usr/bin/python | ||||||||||
# -*- coding: utf-8 -*- | ||||||||||
|
||||||||||
# Copyright (c) 2017, Eike Frost <[email protected]> | ||||||||||
# Copyright (c) 2021, Christophe Gilles <[email protected]> | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You should update this ;-) |
||||||||||
# 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_component | ||||||||||
|
||||||||||
short_description: Allows administration of Keycloak components via Keycloak API | ||||||||||
|
||||||||||
version_added: 9.5.0 | ||||||||||
fivetide marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
|
||||||||||
description: | ||||||||||
- This module allows the administration of Keycloak components via the Keycloak REST API. It | ||||||||||
requires access to the REST API via OpenID Connect; the user connecting and the realm 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 realm definition with the scope tailored | ||||||||||
fivetide marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
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/latest/rest-api/index.html). | ||||||||||
Aliases are provided so camelCased versions can be used as well. | ||||||||||
|
||||||||||
attributes: | ||||||||||
check_mode: | ||||||||||
support: full | ||||||||||
diff_mode: | ||||||||||
support: full | ||||||||||
|
||||||||||
options: | ||||||||||
state: | ||||||||||
description: | ||||||||||
- State of the Keycloak component. | ||||||||||
- On V(present), the component will be created (or updated if it exists already). | ||||||||||
- On V(absent), the component will be removed if it exists. | ||||||||||
choices: ['present', 'absent'] | ||||||||||
default: 'present' | ||||||||||
type: str | ||||||||||
name: | ||||||||||
description: | ||||||||||
- Name of the component to create. | ||||||||||
type: str | ||||||||||
required: true | ||||||||||
parent_id: | ||||||||||
description: | ||||||||||
- The parent_id of the component. In practice the ID (name) of the realm. | ||||||||||
type: str | ||||||||||
required: true | ||||||||||
provider_id: | ||||||||||
description: | ||||||||||
- The name of the "provider ID" for the key. | ||||||||||
type: str | ||||||||||
required: true | ||||||||||
provider_type: | ||||||||||
description: | ||||||||||
- The name of the "provider type" for the key. That is, V(org.keycloak.storage.UserStorageProvider), | ||||||||||
V(org.keycloak.userprofile.UserProfileProvider), ... | ||||||||||
- See U(https://www.keycloak.org/docs/latest/server_development/index.html#_providers). | ||||||||||
type: str | ||||||||||
required: true | ||||||||||
config: | ||||||||||
description: | ||||||||||
- Dict specifying the key and its properties. Contents vary depending on the provider type. | ||||||||||
fivetide marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||
type: dict | ||||||||||
|
||||||||||
extends_documentation_fragment: | ||||||||||
- community.general.keycloak | ||||||||||
- community.general.attributes | ||||||||||
|
||||||||||
author: | ||||||||||
- Björn Bösel (@fivetide) | ||||||||||
''' | ||||||||||
|
||||||||||
EXAMPLES = ''' | ||||||||||
- name: Manage Keycloak User Storage Provider | ||||||||||
community.general.keycloak_component: | ||||||||||
auth_keycloak_url: http://localhost:8080/auth | ||||||||||
auth_username: keycloak | ||||||||||
auth_password: keycloak | ||||||||||
auth_realm: master | ||||||||||
name: my storage provider | ||||||||||
state: present | ||||||||||
parent_id: some_realm | ||||||||||
provider_id: my storage | ||||||||||
provider_type: "org.keycloak.storage.UserStorageProvider" | ||||||||||
config: | ||||||||||
myCustomKey: "my_custom_key" | ||||||||||
cachePolicy: "NO_CACHE" | ||||||||||
enabled: true | ||||||||||
''' | ||||||||||
|
||||||||||
RETURN = ''' | ||||||||||
end_state: | ||||||||||
description: Representation of the keycloak_component after module execution. | ||||||||||
returned: on success | ||||||||||
type: dict | ||||||||||
contains: | ||||||||||
id: | ||||||||||
description: ID of the component. | ||||||||||
type: str | ||||||||||
returned: when O(state=present) | ||||||||||
sample: 5b7ec13f-99da-46ad-8326-ab4c73cf4ce4 | ||||||||||
name: | ||||||||||
description: Name of the component. | ||||||||||
type: str | ||||||||||
returned: when O(state=present) | ||||||||||
sample: mykey | ||||||||||
parentId: | ||||||||||
description: ID of the realm this key belongs to. | ||||||||||
type: str | ||||||||||
returned: when O(state=present) | ||||||||||
sample: myrealm | ||||||||||
providerId: | ||||||||||
description: The ID of the key provider. | ||||||||||
type: str | ||||||||||
returned: when O(state=present) | ||||||||||
sample: rsa | ||||||||||
providerType: | ||||||||||
description: The type of provider. | ||||||||||
type: str | ||||||||||
returned: when O(state=present) | ||||||||||
config: | ||||||||||
description: component configuration. | ||||||||||
type: dict | ||||||||||
''' | ||||||||||
|
||||||||||
from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ | ||||||||||
keycloak_argument_spec, get_token, KeycloakError | ||||||||||
from ansible.module_utils.basic import AnsibleModule | ||||||||||
from ansible.module_utils.six.moves.urllib.parse import urlencode | ||||||||||
from copy import deepcopy | ||||||||||
|
||||||||||
|
||||||||||
def main(): | ||||||||||
""" | ||||||||||
Module execution | ||||||||||
|
||||||||||
:return: | ||||||||||
""" | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMO the code is cleaner without this. |
||||||||||
argument_spec = keycloak_argument_spec() | ||||||||||
|
||||||||||
meta_args = dict( | ||||||||||
state=dict(type='str', default='present', choices=['present', 'absent']), | ||||||||||
name=dict(type='str', required=True), | ||||||||||
parent_id=dict(type='str', required=True), | ||||||||||
provider_id=dict(type='str', required=True), | ||||||||||
provider_type=dict(type='str', required=True), | ||||||||||
config=dict( | ||||||||||
type='dict', | ||||||||||
) | ||||||||||
) | ||||||||||
|
||||||||||
argument_spec.update(meta_args) | ||||||||||
|
||||||||||
module = AnsibleModule(argument_spec=argument_spec, | ||||||||||
supports_check_mode=True, | ||||||||||
required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), | ||||||||||
required_together=([['auth_realm', 'auth_username', 'auth_password']])) | ||||||||||
|
||||||||||
# Initialize the result object. Only "changed" seems to have special | ||||||||||
# meaning for Ansible. | ||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A bit redundant to describe how module results look like. And, to be fair,
Suggested change
|
||||||||||
result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) | ||||||||||
|
||||||||||
# This will include the current state of the component if it is already | ||||||||||
# present. This is only used for diff-mode. | ||||||||||
before_component = {} | ||||||||||
before_component['config'] = {} | ||||||||||
|
||||||||||
# 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) | ||||||||||
|
||||||||||
params_to_ignore = list(keycloak_argument_spec().keys()) + ["state", "parent_id"] | ||||||||||
|
||||||||||
# Filter and map the parameters names that apply to the role | ||||||||||
component_params = [x for x in module.params | ||||||||||
if x not in params_to_ignore and | ||||||||||
module.params.get(x) is not None] | ||||||||||
|
||||||||||
provider_type = module.params.get("provider_type") | ||||||||||
|
||||||||||
# Build a proposed changeset from parameters given to this module | ||||||||||
changeset = {} | ||||||||||
changeset['config'] = {} | ||||||||||
|
||||||||||
# Generate a JSON payload for Keycloak Admin API from the module | ||||||||||
# parameters. Parameters that do not belong to the JSON payload (e.g. | ||||||||||
# "state" or "auth_keycloal_url") have been filtered away earlier (see | ||||||||||
# above). | ||||||||||
# | ||||||||||
# This loop converts Ansible module parameters (snake-case) into | ||||||||||
# Keycloak-compatible format (camel-case). For example private_key | ||||||||||
# becomes privateKey. | ||||||||||
# | ||||||||||
# It also converts bool, str and int parameters into lists with a single | ||||||||||
# entry of 'str' type. Bool values are also lowercased. This is required | ||||||||||
# by Keycloak. | ||||||||||
# | ||||||||||
for component_param in component_params: | ||||||||||
if component_param == 'config': | ||||||||||
for config_param in module.params.get('config'): | ||||||||||
changeset['config'][camel(config_param)] = [] | ||||||||||
raw_value = module.params.get('config')[config_param] | ||||||||||
if isinstance(raw_value, bool): | ||||||||||
value = str(raw_value).lower() | ||||||||||
else: | ||||||||||
value = str(raw_value) | ||||||||||
|
||||||||||
changeset['config'][camel(config_param)].append(value) | ||||||||||
else: | ||||||||||
# No need for camelcase in here as these are one word parameters | ||||||||||
new_param_value = module.params.get(component_param) | ||||||||||
changeset[camel(component_param)] = new_param_value | ||||||||||
|
||||||||||
# Make a deep copy of the changeset. This is use when determining | ||||||||||
# changes to the current state. | ||||||||||
changeset_copy = deepcopy(changeset) | ||||||||||
|
||||||||||
# Make it easier to refer to current module parameters | ||||||||||
name = module.params.get('name') | ||||||||||
force = module.params.get('force') | ||||||||||
state = module.params.get('state') | ||||||||||
enabled = module.params.get('enabled') | ||||||||||
provider_id = module.params.get('provider_id') | ||||||||||
provider_type = module.params.get('provider_type') | ||||||||||
parent_id = module.params.get('parent_id') | ||||||||||
|
||||||||||
# Get a list of all Keycloak components that are of keyprovider type. | ||||||||||
current_components = kc.get_components(urlencode(dict(type=provider_type)), parent_id) | ||||||||||
|
||||||||||
# If this component is present get its key ID. Confusingly the key ID is | ||||||||||
# also known as the Provider ID. | ||||||||||
component_id = None | ||||||||||
|
||||||||||
# Track individual parameter changes | ||||||||||
changes = "" | ||||||||||
|
||||||||||
# This tells Ansible whether the key was changed (added, removed, modified) | ||||||||||
result['changed'] = False | ||||||||||
|
||||||||||
# Loop through the list of components. If we encounter a component whose | ||||||||||
# name matches the value of the name parameter then assume the key is | ||||||||||
# already present. | ||||||||||
for component in current_components: | ||||||||||
if component['name'] == name: | ||||||||||
component_id = component['id'] | ||||||||||
changeset['id'] = component_id | ||||||||||
changeset_copy['id'] = component_id | ||||||||||
|
||||||||||
# Compare top-level parameters | ||||||||||
for param, value in changeset.items(): | ||||||||||
before_component[param] = component[param] | ||||||||||
|
||||||||||
if changeset_copy[param] != component[param] and param != 'config': | ||||||||||
changes += "%s: %s -> %s, " % (param, component[param], changeset_copy[param]) | ||||||||||
result['changed'] = True | ||||||||||
# Compare parameters under the "config" key | ||||||||||
for p, v in changeset_copy['config'].items(): | ||||||||||
try: | ||||||||||
before_component['config'][p] = component['config'][p] or [] | ||||||||||
except KeyError: | ||||||||||
before_component['config'][p] = [] | ||||||||||
if changeset_copy['config'][p] != component['config'][p]: | ||||||||||
changes += "config.%s: %s -> %s, " % (p, component['config'][p], changeset_copy['config'][p]) | ||||||||||
result['changed'] = True | ||||||||||
|
||||||||||
# Check all the possible states of the resource and do what is needed to | ||||||||||
# converge current state with desired state (create, update or delete | ||||||||||
# the key). | ||||||||||
if component_id and state == 'present': | ||||||||||
if result['changed']: | ||||||||||
if module._diff: | ||||||||||
result['diff'] = dict(before=before_component, after=changeset_copy) | ||||||||||
|
||||||||||
if module.check_mode: | ||||||||||
result['msg'] = "Component %s would be changed: %s" % (name, changes.strip(", ")) | ||||||||||
else: | ||||||||||
kc.update_component(changeset, parent_id) | ||||||||||
result['msg'] = "Component %s changed: %s" % (name, changes.strip(", ")) | ||||||||||
else: | ||||||||||
result['msg'] = "Component %s was in sync" % (name) | ||||||||||
|
||||||||||
result['end_state'] = changeset_copy | ||||||||||
elif component_id and state == 'absent': | ||||||||||
if module._diff: | ||||||||||
result['diff'] = dict(before=before_component, after={}) | ||||||||||
|
||||||||||
if module.check_mode: | ||||||||||
result['changed'] = True | ||||||||||
result['msg'] = "Component %s would be deleted" % (name) | ||||||||||
else: | ||||||||||
kc.delete_component(component_id, parent_id) | ||||||||||
result['changed'] = True | ||||||||||
result['msg'] = "Component %s deleted" % (name) | ||||||||||
|
||||||||||
result['end_state'] = {} | ||||||||||
elif not component_id and state == 'present': | ||||||||||
if module._diff: | ||||||||||
result['diff'] = dict(before={}, after=changeset_copy) | ||||||||||
|
||||||||||
if module.check_mode: | ||||||||||
result['changed'] = True | ||||||||||
result['msg'] = "Component %s would be created" % (name) | ||||||||||
else: | ||||||||||
kc.create_component(changeset, parent_id) | ||||||||||
result['changed'] = True | ||||||||||
result['msg'] = "Component %s created" % (name) | ||||||||||
|
||||||||||
result['end_state'] = changeset_copy | ||||||||||
elif not component_id and state == 'absent': | ||||||||||
result['changed'] = False | ||||||||||
result['msg'] = "Component %s not present" % (name) | ||||||||||
result['end_state'] = {} | ||||||||||
|
||||||||||
module.exit_json(**result) | ||||||||||
|
||||||||||
|
||||||||||
if __name__ == '__main__': | ||||||||||
main() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove the changelog fragment. New modules shouldn't have one (see https://github.com/ansible-collections/community.general/blob/main/CONTRIBUTING.md#creating-new-modules-or-plugins).