diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index b498cdf9ea1..d5e3482f9ed 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -851,6 +851,8 @@ files: maintainers: fynncfchen $modules/keycloak_realm_key.py: maintainers: mattock + $modules/keycloak_realm_localization.py: + maintainers: danekja $modules/keycloak_role.py: maintainers: laurpaum $modules/keycloak_user.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index f5cb6892871..a90840f7dcd 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -40,6 +40,7 @@ action_groups: - keycloak_realm - keycloak_realm_key - keycloak_realm_keys_metadata_info + - keycloak_realm_localization - keycloak_realm_rolemapping - keycloak_role - keycloak_user diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 70cf627e33a..5cea9914fef 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -21,6 +21,9 @@ URL_REALM = "{url}/admin/realms/{realm}" URL_REALM_KEYS_METADATA = "{url}/admin/realms/{realm}/keys" +URL_LOCALIZATIONS = "{url}/admin/realms/{realm}/localization/{locale}" +URL_LOCALIZATION = "{url}/admin/realms/{realm}/localization/{locale}/{key}" + URL_TOKEN = "{url}/realms/{realm}/protocol/openid-connect/token" URL_CLIENT = "{url}/admin/realms/{realm}/clients/{id}" URL_CLIENTS = "{url}/admin/realms/{realm}/clients" @@ -367,7 +370,7 @@ def __init__(self, module, connection_header): self.restheaders = connection_header self.http_agent = self.module.params.get('http_agent') - def _request(self, url, method, data=None): + def _request(self, url, method, data=None, headers=None): """ Makes a request to Keycloak and returns the raw response. If a 401 is returned, attempts to re-authenticate using first the module's refresh_token (if provided) @@ -378,12 +381,13 @@ def _request(self, url, method, data=None): :param url: request path :param method: request method (e.g., 'GET', 'POST', etc.) :param data: (optional) data for request + :param headers headers to be sent with request, defaults to self.restheaders :return: raw API response """ - def make_request_catching_401(): + def make_request_catching_401(headers): try: return open_url(url, method=method, data=data, - http_agent=self.http_agent, headers=self.restheaders, + http_agent=self.http_agent, headers=headers, timeout=self.connection_timeout, validate_certs=self.validate_certs) except HTTPError as e: @@ -391,7 +395,10 @@ def make_request_catching_401(): raise e return e - r = make_request_catching_401() + if headers is None: + headers = self.restheaders + + r = make_request_catching_401(headers) if isinstance(r, Exception): # Try to refresh token and retry, if available @@ -568,6 +575,78 @@ def delete_realm(self, realm="master"): self.fail_request(e, msg='Could not delete realm %s: %s' % (realm, str(e)), exception=traceback.format_exc()) + def get_localization_values(self, locale, realm="master"): + """ + Get all localization overrides for a given realm and locale. + + Parameters: + locale (str): Locale code (for example, 'en', 'fi', 'de'). + realm (str): Realm name. Defaults to 'master'. + + Returns: + dict[str, str]: Mapping of localization keys to override values. + + Raises: + KeycloakError: Wrapped HTTP/JSON error with context + """ + realm_url = URL_LOCALIZATIONS.format(url=self.baseurl, realm=realm, locale=locale) + + try: + return self._request_and_deserialize(realm_url, method='GET') + except Exception as e: + self.fail_request(e, msg=f'Could not read localization overrides for realm {realm}, locale {locale}: {e}', + exception=traceback.format_exc()) + + def set_localization_value(self, locale, key, value, realm="master"): + """ + Create or update a single localization override for the given key. + + Parameters: + locale (str): Locale code (for example, 'en'). + key (str): Localization message key to set. + value (str): Override value to set. + realm (str): Realm name. Defaults to 'master'. + + Returns: + HTTPResponse: Response object on success. + + Raises: + KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + headers = self.restheaders.copy() + headers['Content-Type'] = 'text/plain; charset=utf-8' + + try: + return self._request(realm_url, method='PUT', data=to_native(value), headers=headers) + except Exception as e: + self.fail_request(e, msg=f'Could not set localization value in realm {realm}, locale {locale}: {key}={value}: {e}', + exception=traceback.format_exc()) + + def delete_localization_value(self, locale, key, realm="master"): + """ + Delete a single localization override key for the given locale. + + Parameters: + locale (str): Locale code (for example, 'en'). + key (str): Localization message key to delete. + realm (str): Realm name. Defaults to 'master'. + + Returns: + HTTPResponse: Response object on success. + + Raises: + KeycloakError: Wrapped HTTP error with context + """ + realm_url = URL_LOCALIZATION.format(url=self.baseurl, realm=realm, locale=locale, key=key) + + try: + return self._request(realm_url, method='DELETE') + except Exception as e: + self.fail_request(e, msg=f'Could not delete localization value in realm {realm}, locale {locale}, key {key}: {e}', + exception=traceback.format_exc()) + def get_clients(self, realm='master', filter=None): """ Obtains client representations for clients in a realm diff --git a/plugins/modules/keycloak_realm_localization.py b/plugins/modules/keycloak_realm_localization.py new file mode 100644 index 00000000000..bacd1769f40 --- /dev/null +++ b/plugins/modules/keycloak_realm_localization.py @@ -0,0 +1,349 @@ +# Python +# !/usr/bin/python + +# Copyright Jakub Danek +# 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_realm_localization + +short_description: Manage Keycloak realm localization overrides via the Keycloak API + +version_added: 12.0.0 + +description: + - Manage per-locale message overrides for a Keycloak realm using the Keycloak Admin REST API. + - Requires access via OpenID Connect; the connecting user/client must have sufficient privileges. + - The names of module options are snake_cased versions of the names found in the Keycloak API. + +attributes: + check_mode: + support: full + diff_mode: + support: full + +options: + locale: + description: + - Locale code for which the overrides apply (for example, V(en), V(fi), V(de)). + type: str + required: true + parent_id: + description: + - Name of the realm that owns the locale overrides. + type: str + required: true + state: + description: + - Desired state of localization overrides for the given locale. + - On V(present), the set of overrides for the locale will be made to match O(overrides) exactly. + - Keys not listed in O(overrides) will be removed, and the listed keys will be created or updated. + - On V(absent), all overrides for the locale will be removed. + type: str + choices: ['present', 'absent'] + default: present + overrides: + description: + - List of overrides to ensure for the locale when O(state=present). Each item is a mapping with + the record's O(overrides[].key) and its O(overrides[].value). + - Ignored when O(state=absent). + type: list + elements: dict + default: [] + suboptions: + key: + description: + - The message key to override. + type: str + required: true + value: + description: + - The override value for the message key. + type: str + required: true + +seealso: + - module: community.general.keycloak_realm + description: You can specify list of supported locales using O(community.general.keycloak_realm#module:supported_locales). + +extends_documentation_fragment: + - community.general.keycloak + - community.general.keycloak.actiongroup_keycloak + - community.general.attributes + +author: Jakub Danek (@danekja) +""" + +EXAMPLES = r""" +- name: Replace all overrides for locale "en" (credentials auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + overrides: + - key: greeting + value: "Hello" + - key: farewell + value: "Bye" + delegate_to: localhost + +- name: Ensure only one override exists for locale "fi" (token auth) + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + parent_id: my-realm + locale: fi + state: present + overrides: + - key: app.title + value: "Sovellukseni" + delegate_to: localhost + +- name: Remove all overrides for locale "de" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: de + state: absent + delegate_to: localhost + +- name: Dry run - see what would change for locale "en" + community.general.keycloak_realm_localization: + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + parent_id: my-realm + locale: en + state: present + overrides: + - key: greeting + value: "Hello again" + check_mode: true + delegate_to: localhost +""" + +RETURN = r""" +end_state: + description: + - Final state of localization overrides for the locale after module execution. + - Contains the O(locale) and the list of O(overrides) as key/value items. + returned: on success + type: dict + contains: + locale: + description: The locale code affected. + type: str + sample: en + overrides: + description: The list of overrides that exist after execution. + type: list + elements: dict + sample: + - key: greeting + value: Hello + - key: farewell + value: Bye +""" + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI, \ + keycloak_argument_spec, get_token, KeycloakError +from ansible.module_utils.basic import AnsibleModule +from copy import deepcopy + + +def _normalize_overrides_from_api(current): + """ + Accepts: + - dict: {'k1': 'v1', ...} + Return a sorted list of {'key', 'value'}. + + This helper provides a consistent shape for downstream comparison/diff logic. + """ + if not current: + return [] + + # Convert mapping to list of key/value dicts + items = [{'key': k, 'value': v} for k, v in sorted(current.items())] + + # Sort for stable comparisons and diff output + return items + + +def main(): + """ + Module execution + + :return: + """ + # Base Keycloak auth/spec fragment common across Keycloak modules + argument_spec = keycloak_argument_spec() + + # Describe a single override record + overrides_spec = dict( + key=dict(type='str', no_log=False, required=True), + value=dict(type='str', required=True), + ) + + # Module-specific arguments + meta_args = dict( + locale=dict(type='str', required=True), + parent_id=dict(type='str', required=True), + state=dict(type='str', default='present', choices=['present', 'absent']), + overrides=dict(type='list', elements='dict', options=overrides_spec, default=[]), + ) + + argument_spec.update(meta_args) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True, + # Require token OR full credential set. This mirrors other Keycloak modules. + required_one_of=([['token', 'auth_realm', 'auth_username', 'auth_password']]), + required_together=([['auth_realm', 'auth_username', 'auth_password']])) + + # Initialize the result object used by Ansible + result = dict(changed=False, msg='', end_state={}, diff=dict(before={}, after={})) + + # 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) + + # Convenience locals for frequently used parameters + locale = module.params.get('locale') + state = module.params.get('state') + parent_id = module.params.get('parent_id') + + desired_raw = module.params.get('overrides') or [] + desired_map = {r['key']: r.get('value') for r in desired_raw} + desired_overrides = [{'key': k, 'value': v} for k, v in sorted(desired_map.items())] + + # Fetch current overrides and normalize to comparable structure + old_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + before = { + 'locale': locale, + 'overrides': deepcopy(old_overrides), + } + + # Proposed state used for diff reporting + changeset = { + 'locale': locale, + 'overrides': [], + } + + # Default to no change; flip to True when updates/deletes are needed + result['changed'] = False + + if state == 'present': + + changeset['overrides'] = deepcopy(desired_overrides) + + # Compute two sets: + # - to_update: keys missing or with different values + # - to_remove: keys existing in current state but not in desired + to_update = [] + to_remove = deepcopy(old_overrides) + + # Mark updates and remove matched ones from to_remove + for record in desired_overrides: + override_found = False + + for override in to_remove: + + if override['key'] == record['key']: + override_found = True + + # Value differs -> update needed + if override['value'] != record['value']: + result['changed'] = True + to_update.append(record) + + # Remove processed item so what's left in to_remove are deletions + to_remove.remove(override) + break + + if not override_found: + # New key, must be created + to_update.append(record) + result['changed'] = True + + # Any leftovers in to_remove must be deleted + if to_remove: + result['changed'] = True + + if result['changed']: + if module._diff: + result['diff'] = dict(before=before, after=changeset) + + if module.check_mode: + # Dry-run: report intent without side effects + result['msg'] = f"Locale {locale} overrides would be updated." + + else: + + for override in to_remove: + kc.delete_localization_value(locale, override['key'], parent_id) + + for override in to_update: + kc.set_localization_value(locale, override['key'], override['value'], parent_id) + + result['msg'] = f"Locale {locale} overrides have been updated." + + else: + result['msg'] = f"Locale {locale} overrides are in sync." + + # For accurate end_state, read back from API unless we are in check_mode + if not module.check_mode: + final_overrides = _normalize_overrides_from_api(kc.get_localization_values(locale, parent_id) or {}) + + else: + final_overrides = ['overrides'] + + result['end_state'] = {'locale': locale, 'overrides': final_overrides} + + elif state == 'absent': + # Full removal of locale overrides + + if module._diff: + result['diff'] = dict(before=before, after=changeset) + + if module.check_mode: + + if old_overrides: + result['changed'] = True + result['msg'] = f"All overrides for locale {locale} would be deleted." + else: + result['msg'] = f"No overrides for locale {locale} to be deleted." + + else: + + for override in old_overrides: + kc.delete_localization_value(locale, override['key'], parent_id) + result['changed'] = True + + result['msg'] = f"Locale {locale} has no overrides." + + result['end_state'] = changeset + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_keycloak_realm_localization.py b/tests/unit/plugins/modules/test_keycloak_realm_localization.py new file mode 100644 index 00000000000..09a35fce179 --- /dev/null +++ b/tests/unit/plugins/modules/test_keycloak_realm_localization.py @@ -0,0 +1,265 @@ +# Python + +# Copyright Jakub Danek +# 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 + +from contextlib import contextmanager + +import unittest +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_realm_localization + +from itertools import count + +from io import StringIO + + +@contextmanager +def patch_keycloak_api(get_localization_values=None, set_localization_value=None, delete_localization_value=None): + """ + Patch KeycloakAPI methods used by the module under test. + """ + obj = keycloak_realm_localization.KeycloakAPI + with patch.object(obj, 'get_localization_values', side_effect=get_localization_values) as mock_get_values: + with patch.object(obj, 'set_localization_value', side_effect=set_localization_value) as mock_set_value: + with patch.object(obj, 'delete_localization_value', side_effect=delete_localization_value) as mock_del_value: + yield mock_get_values, mock_set_value, mock_del_value + + +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 TestKeycloakRealmLocalization(ModuleTestCase): + def setUp(self): + super().setUp() + self.module = keycloak_realm_localization + + def test_present_no_change_in_sync(self): + """Desired overrides already match, no change.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'greeting', 'value': 'Hello'}, + {'key': 'farewell', 'value': 'Bye'}, + ], + } + # get_localization_values is called twice: before and after + return_value_get_localization_values = [ + {'greeting': 'Hello', 'farewell': 'Bye'}, + {'greeting': 'Hello', 'farewell': 'Bye'}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + self.assertEqual(mock_set_value.call_count, 0) + self.assertEqual(mock_del_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]['changed'], False) + + def test_present_creates_updates_and_deletes(self): + """Create missing, update differing, and delete extra overrides.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'a', 'value': '1-new'}, # update + {'key': 'c', 'value': '3'}, # create + ], + } + # Before: a=1, b=2; After: a=1-new, c=3 + return_value_get_localization_values = [ + {'a': '1', 'b': '2'}, + {'a': '1-new', 'c': '3'}, + ] + return_value_set = [None, None] + return_value_delete = [None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + set_localization_value=return_value_set, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 2) + # One delete for 'b' + self.assertEqual(mock_del_value.call_count, 1) + # Two set calls: update 'a', create 'c' + self.assertEqual(mock_set_value.call_count, 2) + self.assertIs(exec_info.exception.args[0]['changed'], True) + + def test_present_check_mode_only_reports(self): + """Check mode: report changes, do not call API mutators.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'x', 'value': '1'}, # change + {'key': 'y', 'value': '2'}, # create + ], + '_ansible_check_mode': True, # signal for readers; set_module_args is what matters + } + return_value_get_localization_values = [ + {'x': '0'}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + # Only read current values + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_set_value.call_count, 0) + self.assertEqual(mock_del_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]['changed'], True) + self.assertIn('would be updated', exec_info.exception.args[0]['msg']) + + def test_absent_deletes_all(self): + """Remove all overrides when present.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'absent', + } + return_value_get_localization_values = [ + {'k1': 'v1', 'k2': 'v2'}, + ] + return_value_delete = [None, None] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api( + get_localization_values=return_value_get_localization_values, + delete_localization_value=return_value_delete, + ) as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_del_value.call_count, 2) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]['changed'], True) + + def test_absent_idempotent_when_nothing_to_delete(self): + """No change when locale has no overrides.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'absent', + } + return_value_get_localization_values = [ + {}, + ] + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api(get_localization_values=return_value_get_localization_values) \ + as (mock_get_values, mock_set_value, mock_del_value): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(mock_get_values.call_count, 1) + self.assertEqual(mock_del_value.call_count, 0) + self.assertEqual(mock_set_value.call_count, 0) + self.assertIs(exec_info.exception.args[0]['changed'], False) + + def test_present_missing_value_validation(self): + """Validation error when state=present and value is missing.""" + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'token': '{{ access_token }}', + 'parent_id': 'my-realm', + 'locale': 'en', + 'state': 'present', + 'overrides': [ + {'key': 'greeting'}, + ], + } + + with set_module_args(module_args): + with mock_good_connection(): + with patch_keycloak_api() \ + as (_mock_get_values, _mock_set_value, _mock_del_value): + with self.assertRaises(AnsibleFailJson) as exec_info: + self.module.main() + + self.assertIn("missing required arguments: value", exec_info.exception.args[0]['msg']) + + +if __name__ == '__main__': + unittest.main()