diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 251bb71bff..19025f6f45 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -887,6 +887,8 @@ files: maintainers: danekja $modules/keycloak_realm_rolemapping.py: maintainers: agross mhuysamen Gaetan2907 + $modules/keycloak_realm_users_info.py: + maintainers: felix-grzelka $modules/keycloak_role.py: maintainers: laurpaum $modules/keycloak_user.py: diff --git a/meta/runtime.yml b/meta/runtime.yml index bc1814aa3e..6bc6896c82 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -44,6 +44,7 @@ action_groups: - keycloak_realm_keys_metadata_info - keycloak_realm_localization - keycloak_realm_rolemapping + - keycloak_realm_users_info - keycloak_role - keycloak_user - keycloak_user_federation diff --git a/plugins/module_utils/_keycloak.py b/plugins/module_utils/_keycloak.py index a452734e84..5097c1e1e9 100644 --- a/plugins/module_utils/_keycloak.py +++ b/plugins/module_utils/_keycloak.py @@ -1085,24 +1085,21 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not fetch effective rolemappings for user {uid}, realm {realm}: {e}") - def get_user_by_username(self, username, realm: str = "master"): + def get_user_by_username(self, username: str, realm: str = "master") -> dict[str, t.Any] | None: """Fetch a keycloak user within a realm based on its username. - If the user does not exist, None is returned. + If the username is not found, None is returned. :param username: Username of the user to fetch. :param realm: Realm in which the user resides; default 'master' """ users_url = URL_USERS.format(url=self.baseurl, realm=realm) users_url += f"?username={quote(username, safe='')}&exact=true" try: - userrep = None users = self._request_and_deserialize(users_url, method="GET") for user in users: if user["username"] == username: - userrep = user - break - return userrep - + return user + return None except ValueError as e: self.module.fail_json( msg=f"API returned incorrect JSON when trying to obtain the user for realm {realm} and username {username}: {e}" @@ -1110,6 +1107,22 @@ class KeycloakAPI: except Exception as e: self.fail_request(e, msg=f"Could not obtain the user for realm {realm} and username {username}: {e}") + def get_realm_users(self, realm: str = "master") -> list[dict[str, t.Any]]: + """Obtain list of users from the realm + + :param realm: realm id + :return: list of user representations + """ + users_url = URL_USERS.format(url=self.baseurl, realm=realm) + try: + return self._request_and_deserialize(users_url, method="GET") + except ValueError as e: + self.module.fail_json( + msg=f"API returned incorrect JSON when trying to obtain the users for realm {realm}: {e}" + ) + except Exception as e: + self.fail_request(e, msg=f"Could not obtain the users for realm {realm}: {e}") + def get_service_account_user_by_client_id(self, client_id, realm: str = "master"): """Fetch a keycloak service account user within a realm based on its client_id. diff --git a/plugins/modules/keycloak_clientsecret_info.py b/plugins/modules/keycloak_clientsecret_info.py index 99e8003923..fb304cefb6 100644 --- a/plugins/modules/keycloak_clientsecret_info.py +++ b/plugins/modules/keycloak_clientsecret_info.py @@ -104,11 +104,6 @@ EXAMPLES = r""" """ RETURN = r""" -msg: - description: Textual description of whether we succeeded or failed. - returned: always - type: str - clientsecret_info: description: Representation of the client secret. returned: on success diff --git a/plugins/modules/keycloak_realm_users_info.py b/plugins/modules/keycloak_realm_users_info.py new file mode 100644 index 0000000000..997e63d24a --- /dev/null +++ b/plugins/modules/keycloak_realm_users_info.py @@ -0,0 +1,122 @@ +#!/usr/bin/python + +# 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 annotations + +DOCUMENTATION = r""" +module: keycloak_realm_users_info + +short_description: Retrieve users from a Keycloak realm using the Keycloak API + +version_added: 13.1.0 + +description: + - This module retrieves all users from a specified Keycloak realm using the Keycloak REST API. + - Access to the REST API is performed via OpenID Connect. The user and client used must have the necessary permissions. + - Authentication can be performed either with username/password or with a token. + - The names of module options are snake_case versions of the camelCase ones found in the Keycloak API + and its documentation at U(https://www.keycloak.org/docs-api/18.0/rest-api/index.html). + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + realm: + type: str + description: + - The Keycloak realm from which users should be retrieved. + default: 'master' + +extends_documentation_fragment: + - community.general._keycloak + - community.general._attributes + - community.general._attributes.info_module + +author: + - Felix Grzelka (@felix-grzelka) +""" + +EXAMPLES = r""" +- name: List all users in the "MyCustomRealm" realm using username/password authentication + community.general.keycloak_realm_users_info: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + auth_realm: master + auth_username: USERNAME + auth_password: PASSWORD + delegate_to: localhost + +- name: List all users in the "MyCustomRealm" realm using a token + community.general.keycloak_realm_users_info: + realm: MyCustomRealm + auth_client_id: admin-cli + auth_keycloak_url: https://auth.example.com/auth + token: TOKEN + delegate_to: localhost +""" + +RETURN = r""" +users: + description: List of users in the specified realm. + returned: always + type: list + elements: dict + sample: + - id: "1234-5678-90" + username: "user1" + email: "user1@example.com" + - id: "2345-6789-01" + username: "user2" + email: "user2@example.com" +""" + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.general.plugins.module_utils._keycloak import ( + KeycloakAPI, + KeycloakError, + get_token, + keycloak_argument_spec, +) + + +def main(): + argument_spec = keycloak_argument_spec() + + argument_spec["realm"] = dict(default="master") + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_one_of=( + [["token", "auth_realm", "auth_username", "auth_password", "auth_client_id", "auth_client_secret"]] + ), + required_together=([["auth_username", "auth_password"]]), + required_by={"refresh_token": "auth_realm"}, + ) + + result = dict(changed=False, msg="", users="") + + # 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") + + result["users"] = kc.get_realm_users(realm=realm) + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/keycloak_realm_users_info/README.md b/tests/integration/targets/keycloak_realm_users_info/README.md new file mode 100644 index 0000000000..39ffce670f --- /dev/null +++ b/tests/integration/targets/keycloak_realm_users_info/README.md @@ -0,0 +1,21 @@ + +# Running keycloak_realm_users_info module integration test + +To run Keycloak user module's integration test, start a keycloak server using Docker or Podman: + + podman|docker run -d --rm --name mykeycloak -p 8080:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=password quay.io/keycloak/keycloak:latest start-dev --http-relative-path /auth + +Source Ansible env-setup from ansible github repository + +Run integration tests: + + ansible-test integration keycloak_realm_users_info --allow-unsupported + +Cleanup: + + podman|docker stop mykeycloak + diff --git a/tests/integration/targets/keycloak_realm_users_info/aliases b/tests/integration/targets/keycloak_realm_users_info/aliases new file mode 100644 index 0000000000..bd1f024441 --- /dev/null +++ b/tests/integration/targets/keycloak_realm_users_info/aliases @@ -0,0 +1,5 @@ +# 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 + +unsupported diff --git a/tests/integration/targets/keycloak_realm_users_info/tasks/main.yml b/tests/integration/targets/keycloak_realm_users_info/tasks/main.yml new file mode 100644 index 0000000000..65d4849afc --- /dev/null +++ b/tests/integration/targets/keycloak_realm_users_info/tasks/main.yml @@ -0,0 +1,102 @@ +--- +# 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 +- name: Wait for Keycloak + uri: + url: "{{ url }}/admin/" + status_code: 200 + validate_certs: false + register: result + until: result.status == 200 + retries: 10 + delay: 10 + +- name: Delete realm if exists + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + state: absent + +- name: Create realm + community.general.keycloak_realm: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + id: "{{ realm }}" + realm: "{{ realm }}" + state: present + +- name: Create new groups + community.general.keycloak_group: + auth_keycloak_url: "{{ url }}" + auth_realm: "{{ admin_realm }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + realm: "{{ realm }}" + name: "{{ item.name }}" + state: present + with_items: "{{ keycloak_user_groups }}" + +- name: Create user + community.general.keycloak_user: + auth_keycloak_url: "{{ url }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + auth_realm: "{{ admin_realm }}" + username: "{{ keycloak_username }}" + realm: "{{ realm }}" + first_name: Ceciestes + last_name: Untestes + email: ceciestuntestes@test.com + groups: "{{ keycloak_user_groups }}" + state: present + register: create_result + +- name: Assert user is created + assert: + that: + - create_result.changed + - create_result.end_state.username == 'test' + - create_result.end_state.groups | length == 2 + +- name: Create another user + community.general.keycloak_user: + auth_keycloak_url: "{{ url }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + auth_realm: "{{ admin_realm }}" + username: "testuser2" + realm: "{{ realm }}" + first_name: Ceciestes2 + last_name: Untestes2 + email: ceciestuntestes2@test.com + state: present + register: create_result2 + +- name: Get user info + community.general.keycloak_realm_users_info: + auth_keycloak_url: "{{ url }}" + auth_username: "{{ admin_user }}" + auth_password: "{{ admin_password }}" + auth_realm: "{{ admin_realm }}" + realm: "{{ realm }}" + register: user_infos + +- name: debug + debug: + var: user_infos + +- name: add missing group info for comparison + set_fact: + user_info_with_groups: "{{ [user_infos.users[0] | combine({'groups': ['test', 'test2']}), user_infos.users[1] | combine({'groups': []}) ] }}" + +- name: Assert user info is returned correctly + assert: + that: + - not user_infos.changed + - " user_info_with_groups == [create_result.end_state, create_result2.end_state]" diff --git a/tests/integration/targets/keycloak_realm_users_info/vars/main.yml b/tests/integration/targets/keycloak_realm_users_info/vars/main.yml new file mode 100644 index 0000000000..36c8b11e96 --- /dev/null +++ b/tests/integration/targets/keycloak_realm_users_info/vars/main.yml @@ -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 + +url: http://localhost:8080/auth +admin_realm: master +admin_user: admin +admin_password: password +realm: myrealm + +keycloak_username: test +keycloak_user_groups: + - name: test + state: present + - name: test2