diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 333c741b8f..952264b81d 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -540,6 +540,8 @@ files: maintainers: adamgoossens $modules/identity/keycloak/keycloak_identity_provider.py: maintainers: laurpaum + $modules/identity/keycloak/keycloak_realm_info.py: + maintainers: fynncfchen $modules/identity/keycloak/keycloak_realm.py: maintainers: kris2kris $modules/identity/keycloak/keycloak_role.py: diff --git a/plugins/module_utils/identity/keycloak/keycloak.py b/plugins/module_utils/identity/keycloak/keycloak.py index 0ede2dc0ba..b1355a4fa3 100644 --- a/plugins/module_utils/identity/keycloak/keycloak.py +++ b/plugins/module_utils/identity/keycloak/keycloak.py @@ -38,6 +38,7 @@ from ansible.module_utils.six.moves.urllib.parse import urlencode, quote from ansible.module_utils.six.moves.urllib.error import HTTPError from ansible.module_utils.common.text.converters import to_native, to_text +URL_REALM_INFO = "{url}/realms/{realm}" URL_REALMS = "{url}/admin/realms" URL_REALM = "{url}/admin/realms/{realm}" @@ -230,6 +231,31 @@ class KeycloakAPI(object): self.validate_certs = self.module.params.get('validate_certs') self.restheaders = connection_header + def get_realm_info_by_id(self, realm='master'): + """ Obtain realm public info by id + + :param realm: realm id + :return: dict of real, representation or None if none matching exist + """ + realm_info_url = URL_REALM_INFO.format(url=self.baseurl, realm=realm) + + try: + return json.loads(to_native(open_url(realm_info_url, method='GET', headers=self.restheaders, + validate_certs=self.validate_certs).read())) + + except HTTPError as e: + if e.code == 404: + return None + else: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except ValueError as e: + self.module.fail_json(msg='API returned incorrect JSON when trying to obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + except Exception as e: + self.module.fail_json(msg='Could not obtain realm %s: %s' % (realm, str(e)), + exception=traceback.format_exc()) + def get_realm_by_id(self, realm='master'): """ Obtain realm representation by id diff --git a/plugins/modules/identity/keycloak/keycloak_realm_info.py b/plugins/modules/identity/keycloak/keycloak_realm_info.py new file mode 100644 index 0000000000..a84c9dc767 --- /dev/null +++ b/plugins/modules/identity/keycloak/keycloak_realm_info.py @@ -0,0 +1,132 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: keycloak_realm_info + +short_description: Allows obtaining Keycloak realm public information via Keycloak API + +version_added: 4.3.0 + +description: + - This module allows you to get Keycloak realm public information via the Keycloak REST API. + + - 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/8.0/rest-api/index.html). + + - Attributes are multi-valued in the Keycloak API. All attributes are lists of individual values and will + be returned that way by this module. You may pass single values for attributes when calling the module, + and this will be translated into a list suitable for the API. + +options: + auth_keycloak_url: + description: + - URL to the Keycloak instance. + type: str + required: true + aliases: + - url + validate_certs: + description: + - Verify TLS certificates (do not disable this in production). + type: bool + default: yes + + realm: + type: str + description: + - They Keycloak realm ID. + default: 'master' + +author: + - Fynn Chen (@fynncfchen) +''' + +EXAMPLES = ''' +- name: Get a Keycloak public key + community.general.keycloak_realm_info: + realm: MyCustomRealm + auth_keycloak_url: https://auth.example.com/auth + delegate_to: localhost +''' + +RETURN = ''' +msg: + description: Message as to what action was taken. + returned: always + type: str + +realm_info: + description: + - Representation of the realm public infomation. + returned: always + type: dict + contains: + realm: + description: Realm ID. + type: str + returned: always + sample: MyRealm + public_key: + description: Public key of the realm. + type: str + returned: always + sample: MIIBIjANBgkqhkiG9w0BAQEFAAO... + token-service: + description: Token endpoint URL. + type: str + returned: always + sample: https://auth.example.com/auth/realms/MyRealm/protocol/openid-connect + account-service: + description: Account console URL. + type: str + returned: always + sample: https://auth.example.com/auth/realms/MyRealm/account + tokens-not-before: + description: The token not before. + type: int + returned: always + sample: 0 +''' + +from ansible_collections.community.general.plugins.module_utils.identity.keycloak.keycloak import KeycloakAPI +from ansible.module_utils.basic import AnsibleModule + + +def main(): + """ + Module execution + + :return: + """ + argument_spec = dict( + auth_keycloak_url=dict(type='str', aliases=['url'], required=True, no_log=False), + validate_certs=dict(type='bool', default=True), + + realm=dict(default='master'), + ) + + module = AnsibleModule(argument_spec=argument_spec, + supports_check_mode=True) + + result = dict(changed=False, msg='', realm_info='') + + kc = KeycloakAPI(module, {}) + + realm = module.params.get('realm') + + realm_info = kc.get_realm_info_by_id(realm=realm) + + result['realm_info'] = realm_info + result['msg'] = 'Get realm public info successful for ID {realm}'.format(realm=realm) + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/keycloak_realm_info.py b/plugins/modules/keycloak_realm_info.py new file mode 120000 index 0000000000..a268b5b3f3 --- /dev/null +++ b/plugins/modules/keycloak_realm_info.py @@ -0,0 +1 @@ +./identity/keycloak/keycloak_realm_info.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/identity/keycloak/test_keycloak_realm_info.py b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_realm_info.py new file mode 100644 index 0000000000..b6833d7949 --- /dev/null +++ b/tests/unit/plugins/modules/identity/keycloak/test_keycloak_realm_info.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from contextlib import contextmanager + +from ansible_collections.community.general.tests.unit.compat import unittest +from ansible_collections.community.general.tests.unit.compat.mock import call, patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleExitJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules.identity.keycloak import keycloak_realm_info + +from itertools import count + +from ansible.module_utils.six import StringIO + + +@contextmanager +def patch_keycloak_api(get_realm_info_by_id): + """Mock context manager for patching the methods in PwPolicyIPAClient that contact the IPA server + + Patches the `login` and `_post_json` methods + + Keyword arguments are passed to the mock object that patches `_post_json` + + No arguments are passed to the mock object that patches `login` because no tests require it + + Example:: + + with patch_ipa(return_value={}) as (mock_login, mock_post): + ... + """ + + obj = keycloak_realm_info.KeycloakAPI + with patch.object(obj, 'get_realm_info_by_id', side_effect=get_realm_info_by_id) as mock_get_realm_info_by_id: + yield mock_get_realm_info_by_id + + +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 TestKeycloakRealmRole(ModuleTestCase): + def setUp(self): + super(TestKeycloakRealmRole, self).setUp() + self.module = keycloak_realm_info + + def test_get_public_info(self): + """Get realm public info""" + + module_args = { + 'auth_keycloak_url': 'http://keycloak.url/auth', + 'realm': 'my-realm', + } + return_value = [ + None, + { + "realm": "my-realm", + "public_key": "MIIBIjANBgkqhkiG9w0BAQEF...", + "token-service": "https://auth.mock.com/auth/realms/my-realm/protocol/openid-connect", + "account-service": "https://auth.mock.com/auth/realms/my-realm/account", + "tokens-not-before": 0, + } + ] + + set_module_args(module_args) + + # Run the module + + with mock_good_connection(): + with patch_keycloak_api(get_realm_info_by_id=return_value) \ + as (mock_get_realm_info_by_id): + with self.assertRaises(AnsibleExitJson) as exec_info: + self.module.main() + + self.assertEqual(len(mock_get_realm_info_by_id.mock_calls), 1) + + +if __name__ == '__main__': + unittest.main()