diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 22a7190fb9..4686cd6882 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -861,6 +861,8 @@ files: maintainers: bratwurzt $modules/keycloak_realm_rolemapping.py: maintainers: agross mhuysamen Gaetan2907 + $modules/keycloak_user_execute_actions_email.py: + maintainers: mariusbertram $modules/keyring.py: maintainers: ahussey-redhat $modules/keyring_info.py: diff --git a/changelogs/fragments/10950-keycloak-user-return-user-created.yml b/changelogs/fragments/10950-keycloak-user-return-user-created.yml new file mode 100644 index 0000000000..78abec07f1 --- /dev/null +++ b/changelogs/fragments/10950-keycloak-user-return-user-created.yml @@ -0,0 +1,2 @@ +minor_changes: + - keycloak_user - return user created boolean flag (https://github.com/ansible-collections/community.general/pull/10950). diff --git a/meta/runtime.yml b/meta/runtime.yml index 3fa5e94953..cb9f06f69e 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -46,6 +46,7 @@ action_groups: - keycloak_user_federation - keycloak_user_rolemapping - keycloak_userprofile + - keycloak_user_execute_actions_email scaleway: - scaleway_compute - scaleway_compute_private_network diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 53127d60c4..f8c6c86b3c 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -74,6 +74,7 @@ URL_USER_CLIENTS_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-map URL_USER_CLIENT_ROLE_MAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client_id}" URL_USER_GROUPS = "{url}/admin/realms/{realm}/users/{id}/groups" URL_USER_GROUP = "{url}/admin/realms/{realm}/users/{id}/groups/{group_id}" +URL_EXECUTE_ACTION = "{url}/admin/realms/{realm}/users/{user_id}/execute-actions-email" URL_CLIENT_SERVICE_ACCOUNT_USER = "{url}/admin/realms/{realm}/clients/{id}/service-account-user" URL_CLIENT_USER_ROLEMAPPINGS = "{url}/admin/realms/{realm}/users/{id}/role-mappings/clients/{client}" @@ -3118,3 +3119,39 @@ class KeycloakAPI: :return: None """ return self.fail_request(e, msg, **kwargs) + + def send_execute_actions_email(self, user_id, realm='master', client_id=None, data=None, redirect_uri=None, lifespan=None): + """ + Send an email to the user with a link they can click to perform required actions (e.g. reset password). + Uses execute-actions-email endpoint with provided required actions (defaults handled by caller). + + :param user_id: ID of the user + :param realm: Realm name (not the ID) + :param client_id: Optional client id for the redirect + :param redirect_uri: Optional redirect uri + :param data: List of required action names (list[str]) + :param lifespan: Optional lifespan (seconds) for the action token + :return: HTTP response (204 No Content on success) + """ + try: + execute_action_url = URL_EXECUTE_ACTION.format(url=self.baseurl, realm=realm, user_id=user_id) + + params = {} + if client_id is not None: + params['client_id'] = client_id + if redirect_uri is not None: + params['redirect_uri'] = redirect_uri + if lifespan is not None: + params['lifespan'] = lifespan + + if params: + execute_action_url = f"{execute_action_url}?{urlencode(params)}" + + body = None + if data is not None: + # API expects JSON array of action names + body = json.dumps(data) + + return self._request(execute_action_url, method='PUT', data=body) + except Exception as e: + self.fail_request(e, msg=f'Could not send execute actions email to user {user_id} in realm {realm}: {e}') diff --git a/plugins/modules/keycloak_user.py b/plugins/modules/keycloak_user.py index 0181ae07c5..8181e966d9 100644 --- a/plugins/modules/keycloak_user.py +++ b/plugins/modules/keycloak_user.py @@ -342,6 +342,11 @@ end_state: description: Representation of the user after module execution. returned: on success type: dict +user_created: + description: Indicates whether a user was created. + returned: in success + type: bool + version_added: 12.0.0 """ from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, camel, \ @@ -457,7 +462,8 @@ def main(): result['proposed'] = changeset result['existing'] = before_user - + # Default values for user_created + result['user_created'] = False changed = False # Cater for when it doesn't exist (an empty dict) @@ -493,12 +499,18 @@ def main(): result['diff'] = dict(before='', after=desired_user) if module.check_mode: + # Set user_created flag explicit for check_mode + # create_user could have failed, but we don't know for sure until we try to create the user.' + result['user_created'] = True module.exit_json(**result) + # Create the user after_user = kc.create_user(userrep=desired_user, realm=realm) result["msg"] = f"User {desired_user['username']} created" # Add user ID to new representation desired_user['id'] = after_user["id"] + # Set user_created flag + result['user_created'] = True else: excludes = [ "access", diff --git a/plugins/modules/keycloak_user_execute_actions_email.py b/plugins/modules/keycloak_user_execute_actions_email.py new file mode 100644 index 0000000000..c139c442c9 --- /dev/null +++ b/plugins/modules/keycloak_user_execute_actions_email.py @@ -0,0 +1,197 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2025, mariusbertram +# 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 annotations + +DOCUMENTATION = r""" +module: keycloak_user_execute_actions_email + +short_description: Send a Keycloak execute-actions email to a user + +version_added: 12.0.0 + +description: + - Triggers the Keycloak endpoint C(execute-actions-email) for a user. + This sends an email with one or more required actions the user must complete (for example resetting the password). + - If no O(actions) list is provided, the default action C(UPDATE_PASSWORD) is used. + - You must supply either the user's O(id) or O(username). Supplying only C(username) causes an extra lookup call. + - This module always reports C(changed=true) because sending an email is a side effect and cannot be made idempotent. +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + auth_username: + aliases: [] + realm: + description: + - The Keycloak realm where the user resides. + type: str + default: master + id: + description: + - The unique ID (UUID) of the user. + - Mutually exclusive with O(username). + type: str + username: + description: + - Username of the user. + - Mutually exclusive with O(id). + type: str + actions: + description: + - List of required actions to include in the email. + type: list + elements: str + default: + - UPDATE_PASSWORD + client_id: + description: + - Optional client ID used for the redirect link. + aliases: [clientId] + type: str + redirect_uri: + description: + - Optional redirect URI. Must be valid for the given client if O(client_id) is set. + aliases: [redirectUri] + type: str + lifespan: + description: + - Optional lifespan (in seconds) for the action token (supported on newer Keycloak versions). Forwarded as query parameter if provided. + type: int +extends_documentation_fragment: + - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak + - community.general.attributes +author: + - Marius Bertram (@mariusbertram) +""" + +EXAMPLES = r""" +- name: Password reset email (default action) with 1h lifespan + community.general.keycloak_user_execute_actions_email: + username: johndoe + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: ADMIN + auth_password: SECRET + lifespan: 3600 + delegate_to: localhost + +- name: Multiple required actions using token auth + community.general.keycloak_user_execute_actions_email: + username: johndoe + actions: + - UPDATE_PASSWORD + - VERIFY_EMAIL + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost + +- name: Email by user id with redirect + community.general.keycloak_user_execute_actions_email: + id: 9d59aa76-2755-48c6-b1af-beb70a82c3cd + client_id: my-frontend + redirect_uri: https://app.example.com/post-actions + actions: + - UPDATE_PASSWORD + realm: MyRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: ADMIN + auth_password: SECRET + delegate_to: localhost +""" + +RETURN = r""" +user_id: + description: The user ID the email was (or would be, in check mode) sent to. + returned: success + type: str +actions: + description: List of actions included in the email. + returned: success + type: list + elements: str +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import ( + keycloak_argument_spec, get_token, KeycloakError, KeycloakAPI) + + +def main(): + argument_spec = keycloak_argument_spec() + # Avoid alias collision as in keycloak_user: clear auth_username aliases locally + argument_spec['auth_username']['aliases'] = [] + + meta_args = dict( + realm=dict(type='str', default='master'), + id=dict(type='str'), + username=dict(type='str'), + actions=dict(type='list', elements='str', default=['UPDATE_PASSWORD']), + client_id=dict(type='str', aliases=['clientId']), + redirect_uri=dict(type='str', aliases=['redirectUri']), + lifespan=dict(type='int'), + ) + argument_spec.update(meta_args) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=[['id', 'username']], + mutually_exclusive=[['id', 'username']], + ) + + 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') + user_id = module.params.get('id') + username = module.params.get('username') + actions = module.params.get('actions') + client_id = module.params.get('client_id') + redirect_uri = module.params.get('redirect_uri') + lifespan = module.params.get('lifespan') + + # Resolve user ID if only username is provided + if user_id is None: + user_obj = kc.get_user_by_username(username=username, realm=realm) + if user_obj is None: + module.fail_json(msg=f"User '{username}' not found in realm {realm}") + user_id = user_obj['id'] + + if module.check_mode: + module.exit_json(changed=True, msg=f"Would send execute-actions email to user {user_id}", user_id=user_id, actions=actions) + + try: + kc.send_execute_actions_email( + user_id=user_id, + realm=realm, + client_id=client_id, + data=actions, + redirect_uri=redirect_uri, + lifespan=lifespan + ) + except Exception as e: + module.fail_json(msg=str(e)) + + module.exit_json(changed=True, msg=f"Execute-actions email sent to user {user_id}", user_id=user_id, actions=actions) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_keycloak_user_execute_actions_email.py b/tests/unit/plugins/modules/test_keycloak_user_execute_actions_email.py new file mode 100644 index 0000000000..c6653c3490 --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_user_execute_actions_email.py @@ -0,0 +1,113 @@ +# Copyright (c) 2025, 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 annotations + +import unittest +from contextlib import contextmanager +from unittest.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + AnsibleExitJson, AnsibleFailJson, ModuleTestCase, set_module_args +) + +from ansible_collections.community.general.plugins.modules import keycloak_user_execute_actions_email as module_under_test + +from io import StringIO +from itertools import count + + +def _create_wrapper(text_as_string): + def _wrapper(): + return StringIO(text_as_string) + return _wrapper + + +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) + if callable(future_response): + return future_response() + return future_response + return _mocked_requests + + +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 + ) + + +@contextmanager +def patch_keycloak_api(get_user_by_username=None, send_execute_actions_email=None): + obj = module_under_test.KeycloakAPI + with patch.object(obj, 'get_user_by_username', side_effect=get_user_by_username) as m_get_user: + with patch.object(obj, 'send_execute_actions_email', side_effect=send_execute_actions_email) as m_send: + yield m_get_user, m_send + + +class TestKeycloakUserExecuteActionsEmail(ModuleTestCase): + def setUp(self): + super().setUp() + self.module = module_under_test + + def test_default_action_with_username(self): + module_args = { + 'auth_client_id': 'admin-cli', + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'realm': 'master', + 'username': 'jdoe' + } + + with set_module_args(module_args): + with _mock_good_connection(): + with patch_keycloak_api( + get_user_by_username=lambda **kwargs: {'id': 'uid-123', 'username': 'jdoe'}, + send_execute_actions_email=lambda **kwargs: None, + ) as (m_get_user, m_send): + with self.assertRaises(AnsibleExitJson) as result: + self.module.main() + + data = result.exception.args[0] + self.assertTrue(data['changed']) + self.assertEqual(data['user_id'], 'uid-123') + self.assertEqual(data['actions'], ['UPDATE_PASSWORD']) + self.assertEqual(len(m_get_user.mock_calls), 1) + self.assertEqual(len(m_send.mock_calls), 1) + + def test_user_not_found(self): + module_args = { + 'auth_client_id': 'admin-cli', + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'auth_password': 'admin', + 'auth_realm': 'master', + 'auth_username': 'admin', + 'realm': 'master', + 'username': 'missing' + } + + with set_module_args(module_args): + with _mock_good_connection(): + with patch_keycloak_api( + get_user_by_username=lambda **kwargs: None, + send_execute_actions_email=lambda **kwargs: None, + ): + with self.assertRaises(AnsibleFailJson) as result: + self.module.main() + data = result.exception.args[0] + self.assertIn("User 'missing' not found", data['msg']) + + +if __name__ == '__main__': + unittest.main()