From b146ff0990b2b3fb7c65e4e576d0a3a29a9baac1 Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Thu, 12 Mar 2026 11:09:43 +0100 Subject: [PATCH 1/7] Add vibe-coded role module. --- .github/workflows/ans-int-test-role.yaml | 63 ++ .github/workflows/ans-unit-test-role.yaml | 34 + plugins/modules/role.py | 473 +++++++++++ tests/integration/targets/role/tasks/main.yml | 35 + tests/integration/targets/role/tasks/test.yml | 423 ++++++++++ tests/integration/targets/role/vars/main.yml | 5 + tests/unit/plugins/modules/test_role.py | 758 ++++++++++++++++++ 7 files changed, 1791 insertions(+) create mode 100644 .github/workflows/ans-int-test-role.yaml create mode 100644 .github/workflows/ans-unit-test-role.yaml create mode 100644 plugins/modules/role.py create mode 100644 tests/integration/targets/role/tasks/main.yml create mode 100644 tests/integration/targets/role/tasks/test.yml create mode 100644 tests/integration/targets/role/vars/main.yml create mode 100644 tests/unit/plugins/modules/test_role.py diff --git a/.github/workflows/ans-int-test-role.yaml b/.github/workflows/ans-int-test-role.yaml new file mode 100644 index 000000000..24cf3296f --- /dev/null +++ b/.github/workflows/ans-int-test-role.yaml @@ -0,0 +1,63 @@ +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +name: Module Role Integration + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + pull_request: + branches: + - main + - devel + types: + - opened + paths: + - 'plugins/modules/role.py' + push: + paths: + - '.github/workflows/ans-int-test-role.yaml' + - 'plugins/modules/role.py' + - 'plugins/module_utils/**' + - 'tests/integration/files/includes/**' + - 'tests/integration/targets/role/**' + +jobs: + + integration: + name: "${{ matrix.checkmk_version }}.${{ matrix.checkmk_edition }}" + uses: ./.github/workflows/_template-ans-int-test.yaml + with: + module: role + checkmk_version: ${{ matrix.checkmk_version }} + checkmk_edition: ${{ matrix.checkmk_edition }} + strategy: + fail-fast: false + matrix: + checkmk_version: + - 2.3.0p46 + - 2.4.0p26 + - 2.5.0 + checkmk_edition: + - raw + - managed + - community + - ultimatemt + exclude: + - checkmk_version: 2.3.0p46 + checkmk_edition: community + - checkmk_version: 2.3.0p46 + checkmk_edition: ultimatemt + - checkmk_version: 2.4.0p26 + checkmk_edition: community + - checkmk_version: 2.4.0p26 + checkmk_edition: ultimatemt + - checkmk_version: 2.5.0 + checkmk_edition: raw + - checkmk_version: 2.5.0 + checkmk_edition: managed diff --git a/.github/workflows/ans-unit-test-role.yaml b/.github/workflows/ans-unit-test-role.yaml new file mode 100644 index 000000000..e96a3ce5b --- /dev/null +++ b/.github/workflows/ans-unit-test-role.yaml @@ -0,0 +1,34 @@ +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +name: Module Role Unit Tests + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + pull_request: + branches: + - main + - devel + paths: + - 'plugins/modules/role.py' + push: + paths: + - '.github/workflows/ans-unit-test-role.yaml' + - 'plugins/modules/role.py' + - 'plugins/module_utils/**' + - 'tests/unit/plugins/module_utils/**' + - 'tests/unit/plugins/modules/test_role.py' + +jobs: + + integration: + name: "Unit Tests" + uses: ./.github/workflows/_template-ans-unit-test.yaml + with: + testpath: tests/unit/plugins/modules/test_role.py diff --git a/plugins/modules/role.py b/plugins/modules/role.py new file mode 100644 index 000000000..59762a9be --- /dev/null +++ b/plugins/modules/role.py @@ -0,0 +1,473 @@ +#!/usr/bin/python +# -*- encoding: utf-8; py-indent-offset: 4 -*- + +# Copyright: (c) 2025, Checkmk GmbH +# 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 = r""" +--- +module: role + +short_description: Manage roles in Checkmk. + +version_added: "7.5.0" + +description: + - Manage user roles within Checkmk. Custom roles are created by + cloning an existing built-in role and can then be modified. + - Built-in roles (C(admin), C(user), C(guest), C(agent_registration)) + cannot be created or deleted, but their permissions can be modified + using this module with C(state=present). + +extends_documentation_fragment: + - checkmk.general.common + +options: + name: + description: + - The internal ID of the role. This is used to uniquely + identify the role. It cannot be changed after creation. + required: true + type: str + title: + description: + - The human-readable title (alias) of the role. + - Optional when creating a new custom role. If omitted, the + title of the source role is used. + type: str + aliases: ["alias"] + based_on: + description: + - The ID of the built-in role to clone from when creating + a new custom role. Valid values are C(admin), C(user), + C(guest), and C(agent_registration). + - Required when creating a new custom role. + - This parameter is ignored when updating an existing role. + type: str + choices: ["admin", "user", "guest", "agent_registration"] + permissions: + description: + - A dictionary of permissions to set on the role. + - Keys are permission IDs (e.g., C(general.use), C(wato.edit), + C(wato.all_folders)). + - Values must be one of C(yes), C(no), or C(default). Values + must be quoted strings in YAML; unquoted C(yes) and C(no) are + interpreted as booleans and will be rejected. + - The value C(default) reverts a permission to the base role's + setting. It is only valid for custom roles. For built-in roles + (C(admin), C(user), C(guest), C(agent_registration)) use + C(yes) or C(no) explicitly. + - Permissions not listed here will remain unchanged. + - You can find the internal permission IDs in the Checkmk + GUI under I(Setup > Users > Roles & permissions) using + the inline help (available from Checkmk 2.4.0 onwards + via Werk #17953). + type: dict + state: + description: + - The desired state of the role. + - C(present) ensures the role exists with the specified + configuration. If the role does not exist, it will be + created by cloning the role specified in C(based_on). + - C(absent) ensures the custom role does not exist. + Built-in roles cannot be deleted. + type: str + default: present + choices: ["present", "absent"] + +author: + - "Checkmk GmbH (@checkmk)" + +notes: + - "Idempotency: This module compares the desired configuration + against the current state and only makes changes when necessary." + - "Built-in roles (admin, user, guest, agent_registration) cannot + be created or deleted, but their permissions can be updated." + +seealso: + - module: checkmk.general.user + - name: "Checkmk documentation on roles" + description: "Complete documentation for user roles and permissions." + link: "https://docs.checkmk.com/latest/en/wato_user.html" +""" + +EXAMPLES = r""" +# Create a custom role based on the "user" role. +- name: "Create a custom monitoring role." + checkmk.general.role: + server_url: "http://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "limited_user" + title: "Limited Monitoring User" + based_on: "user" + state: "present" + +# Create a custom role with specific permissions. +- name: "Create a custom role with tailored permissions." + checkmk.general.role: + server_url: "http://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "host_manager" + title: "Host Manager" + based_on: "user" + permissions: + wato.all_folders: "yes" + wato.edit: "yes" + wato.manage_hosts: "yes" + general.edit_notifications: "no" + state: "present" + +# Update permissions on an existing role. +- name: "Update permissions on an existing custom role." + checkmk.general.role: + server_url: "http://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "host_manager" + permissions: + wato.all_folders: "yes" + state: "present" + +# Update permissions on a built-in role. +- name: "Modify permissions on the built-in user role." + checkmk.general.role: + server_url: "http://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "user" + permissions: + general.edit_notifications: "no" + state: "present" + +# Delete a custom role. +- name: "Delete a custom role." + checkmk.general.role: + server_url: "http://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "limited_user" + state: "absent" +""" + +RETURN = r""" +msg: + description: The output message that the module generates. Contains + the API response details in case of an error. + type: str + returned: always + sample: 'Role created.' +http_code: + description: The HTTP code the Checkmk API returns. + type: int + returned: always + sample: 200 +""" + +import json + +# https://docs.ansible.com/ansible/latest/dev_guide/testing/sanity/import.html +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.checkmk.general.plugins.module_utils.api import ( + CheckmkAPI, +) +from ansible_collections.checkmk.general.plugins.module_utils.types import RESULT +from ansible_collections.checkmk.general.plugins.module_utils.utils import ( + base_argument_spec, + result_as_dict, +) + +BUILTIN_ROLES = ("admin", "user", "guest", "agent_registration") +VALID_PERMISSION_VALUES = frozenset(("yes", "no", "default")) + +# 404 is not failed for GET — we use it to detect absence. +HTTP_CODES_GET = { + 200: (False, False, "Role found, nothing changed."), + 404: (False, False, "Role not found."), +} + +HTTP_CODES_CREATE = { + 200: (True, False, "Role created."), +} + +HTTP_CODES_EDIT = { + 200: (True, False, "Role updated."), +} + +HTTP_CODES_DELETE = { + 204: (True, False, "Role deleted."), +} + + +class RoleAPI(CheckmkAPI): + def __init__(self, module): + super().__init__(module) + + self.params = self.module.params + self.state = self.params.get("state") + self.name = self.params.get("name") + self.title = self.params.get("title") + self.based_on = self.params.get("based_on") + self.permissions = self.params.get("permissions") + + self.current = self._fetch( + code_mapping=HTTP_CODES_GET, + endpoint="/objects/user_role/%s" % self.name, + method="GET", + ) + + def _build_edit_data(self): + data = {} + + if self.title is not None: + data["new_alias"] = self.title + + if self.permissions is not None: + data["new_permissions"] = self.permissions + + return data + + def _get_base_role_permissions(self, based_on): + if not based_on: + return set() + result = self._fetch( + code_mapping={200: (False, False, "Base role found.")}, + endpoint="/objects/user_role/%s" % based_on, + method="GET", + ) + if result.http_code != 200: + return set() + content = json.loads(result.content.decode("utf-8")) + return set(content.get("extensions", {}).get("permissions", [])) + + def _needs_update(self): + if self.current.http_code != 200: + return False + + current_content = json.loads(self.current.content.decode("utf-8")) + current_extensions = current_content.get("extensions", {}) + + if self.title is not None: + if current_extensions.get("alias", "") != self.title: + return True + + if self.permissions is not None: + current_perms = set(current_extensions.get("permissions", [])) + has_default = any(v == "default" for v in self.permissions.values()) + base_perms = ( + self._get_base_role_permissions(current_extensions.get("basedon", "")) + if has_default + else set() + ) + + for perm, value in self.permissions.items(): + if value == "yes" and perm not in current_perms: + return True + elif value == "no" and perm in current_perms: + return True + elif value == "default": + if (perm in base_perms) != (perm in current_perms): + return True + + return False + + def create(self): + data = { + "role_id": self.based_on, + "new_role_id": self.name, + } + + if self.title is not None: + data["new_alias"] = self.title + + # Note: permissions cannot be set on create via the API. + # They are applied via a follow-up PUT in run_module() if specified. + + return self._fetch( + code_mapping=HTTP_CODES_CREATE, + endpoint="/domain-types/user_role/collections/all", + data=data, + method="POST", + ) + + def edit(self): + data = self._build_edit_data() + + if not data: + return RESULT( + http_code=200, + msg="Role already up to date.", + content={}, + etag="", + failed=False, + changed=False, + ) + + if self.current.etag: + self.headers["If-Match"] = self.current.etag + return self._fetch( + code_mapping=HTTP_CODES_EDIT, + endpoint="/objects/user_role/%s" % self.name, + data=data, + method="PUT", + ) + + def delete(self): + if self.current.etag: + self.headers["If-Match"] = self.current.etag + return self._fetch( + code_mapping=HTTP_CODES_DELETE, + endpoint="/objects/user_role/%s" % self.name, + method="DELETE", + ) + + +def run_module(): + module_args = base_argument_spec() + module_args.update( + dict( + name=dict(type="str", required=True), + title=dict(type="str", aliases=["alias"]), + based_on=dict( + type="str", + choices=["admin", "user", "guest", "agent_registration"], + ), + permissions=dict(type="dict"), + state=dict( + type="str", + default="present", + choices=["present", "absent"], + ), + ) + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + name = module.params.get("name") + permissions = module.params.get("permissions") + if permissions: + for perm, value in permissions.items(): + if value not in VALID_PERMISSION_VALUES: + module.fail_json( + msg="Invalid permission value '%s' for '%s'. Must be one of: %s." + % (value, perm, ", ".join(sorted(VALID_PERMISSION_VALUES))) + ) + return + if name in BUILTIN_ROLES and value == "default": + module.fail_json( + msg="Permission value 'default' is not valid for built-in role '%s'. " + "Built-in roles have no base role; use 'yes' or 'no' explicitly." + % name + ) + return + + role = RoleAPI(module) + result = RESULT( + http_code=0, + msg="Nothing to be done.", + content={}, + etag="", + failed=False, + changed=False, + ) + + if role.state == "present": + if role.current.http_code == 200: + if role._needs_update(): + if not module.check_mode: + result = role.edit() + else: + result = RESULT( + http_code=200, + msg="Role would be updated.", + content={}, + etag="", + failed=False, + changed=True, + ) + else: + result = RESULT( + http_code=200, + msg="Role already exists with desired state.", + content={}, + etag="", + failed=False, + changed=False, + ) + elif role.current.http_code == 404: + if role.based_on is None: + module.fail_json( + msg="'based_on' is required when creating a new custom role." + ) + return + if not module.check_mode: + result = role.create() + # Permissions cannot be set during create; apply via PUT if needed. + # Null title so the follow-up edit only sends new_permissions. + if not result.failed and role.permissions is not None: + role.title = None + edit_result = role.edit() + if edit_result.failed: + module.fail_json(msg=edit_result.msg) + return + else: + result = RESULT( + http_code=200, + msg="Role would be created.", + content={}, + etag="", + failed=False, + changed=True, + ) + + elif role.state == "absent": + if role.current.http_code == 200: + if role.name in BUILTIN_ROLES: + module.fail_json( + msg="Built-in role '%s' cannot be deleted." % role.name + ) + return + if not module.check_mode: + result = role.delete() + else: + result = RESULT( + http_code=204, + msg="Role would be deleted.", + content={}, + etag="", + failed=False, + changed=True, + ) + elif role.current.http_code == 404: + result = RESULT( + http_code=0, + msg="Role already absent.", + content={}, + etag="", + failed=False, + changed=False, + ) + + module.exit_json(**result_as_dict(result)) + + +def main(): + run_module() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/role/tasks/main.yml b/tests/integration/targets/role/tasks/main.yml new file mode 100644 index 000000000..5ec7ab4db --- /dev/null +++ b/tests/integration/targets/role/tasks/main.yml @@ -0,0 +1,35 @@ +--- +- name: "Include Global Variables." + ansible.builtin.include_vars: "{{ lookup('ansible.builtin.first_found', checkmk_var_params) }}" + vars: + checkmk_var_params: + files: + - global.yml + paths: + - /home/runner/work/ansible-collection-checkmk.general/ansible-collection-checkmk.general/ansible_collections/checkmk/general/tests/integration/files/includes/vars/ + - /root/ansible_collections/checkmk/general/tests/integration/files/includes/vars/ + - tests/integration/files/includes/vars/ + +- name: "Print Identifier." + ansible.builtin.debug: + msg: "{{ ansible_facts['system_vendor'] }} {{ ansible_facts['product_name'] }} running {{ ansible_facts['virtualization_type'] }}" + +- name: "Run preparations." + ansible.builtin.include_tasks: "{{ lookup('ansible.builtin.first_found', checkmk_var_params) }}" + vars: + checkmk_var_params: + files: + - prep.yml + paths: + - /home/runner/work/ansible-collection-checkmk.general/ansible-collection-checkmk.general/ansible_collections/checkmk/general/tests/integration/files/includes/tasks/ + - /root/ansible_collections/checkmk/general/tests/integration/files/includes/tasks/ + - tests/integration/files/includes/tasks/ + when: + - ansible_facts['virtualization_type'] in ['container', 'podman', 'docker'] + - (ansible_facts['system_vendor'] == "Dell Inc." and 'Latitude' in ansible_facts['product_name']) or (ansible_facts['system_vendor'] == "QEMU" and 'Ubuntu' in ansible_facts['product_name']) + +- name: "Testing." + ansible.builtin.include_tasks: test.yml + loop: "{{ checkmk_var_test_sites }}" + loop_control: + loop_var: outer_item diff --git a/tests/integration/targets/role/tasks/test.yml b/tests/integration/targets/role/tasks/test.yml new file mode 100644 index 000000000..303a4ee1c --- /dev/null +++ b/tests/integration/targets/role/tasks/test.yml @@ -0,0 +1,423 @@ +--- +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Set customer attribute." + ansible.builtin.set_fact: + checkmk_var_customer: "{{ 'provider' if outer_item.edition in ['managed', 'ultimatemt'] else None }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role based on user." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_custom_role" + title: "Test Custom Role" + based_on: "user" + state: "present" + register: __checkmk_var_role_create + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role creation." + ansible.builtin.assert: + that: + - __checkmk_var_role_create.changed == true + - "'Role created.' in __checkmk_var_role_create.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create the same custom role again (idempotency)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_custom_role" + title: "Test Custom Role" + based_on: "user" + state: "present" + register: __checkmk_var_role_create_again + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify idempotency." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_again.changed == false + - __checkmk_var_role_create_again.msg == "Role already exists with desired state." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role with permissions." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + title: "Test Role With Permissions" + based_on: "user" + permissions: + general.edit_notifications: "no" + wato.all_folders: "yes" + state: "present" + register: __checkmk_var_role_create_with_perms + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role creation with permissions." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_with_perms.changed == true + - "'Role created.' in __checkmk_var_role_create_with_perms.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create the same role with permissions again (idempotency)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + title: "Test Role With Permissions" + permissions: + general.edit_notifications: "no" + wato.all_folders: "yes" + state: "present" + register: __checkmk_var_role_create_with_perms_again + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify idempotency with permissions." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_with_perms_again.changed == false + - __checkmk_var_role_create_with_perms_again.msg == "Role already exists with desired state." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Set a permission to default on a custom role." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + permissions: + wato.all_folders: "default" + state: "present" + register: __checkmk_var_role_set_default_perm + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify setting permission to default changes role." + ansible.builtin.assert: + that: + - __checkmk_var_role_set_default_perm.changed == true + - "'Role updated.' in __checkmk_var_role_set_default_perm.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Set the same permission to default again (idempotency)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + permissions: + wato.all_folders: "default" + state: "present" + register: __checkmk_var_role_set_default_perm_again + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify default permission is idempotent." + ansible.builtin.assert: + that: + - __checkmk_var_role_set_default_perm_again.changed == false + - __checkmk_var_role_set_default_perm_again.msg == "Role already exists with desired state." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Update the title of an existing custom role." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_custom_role" + title: "Updated Custom Role Title" + state: "present" + register: __checkmk_var_role_update_title + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify title update." + ansible.builtin.assert: + that: + - __checkmk_var_role_update_title.changed == true + - "'Role updated.' in __checkmk_var_role_update_title.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Update permissions on an existing custom role." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + permissions: + general.edit_notifications: "yes" + state: "present" + register: __checkmk_var_role_update_perms + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify permissions update." + ansible.builtin.assert: + that: + - __checkmk_var_role_update_perms.changed == true + - "'Role updated.' in __checkmk_var_role_update_perms.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Modify permissions on the built-in guest role." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "guest" + permissions: + general.csv_export: "no" + state: "present" + register: __checkmk_var_role_builtin_modify + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify built-in role modification." + ansible.builtin.assert: + that: + - __checkmk_var_role_builtin_modify.changed == true + - "'Role updated.' in __checkmk_var_role_builtin_modify.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Revert built-in guest role permissions (cleanup)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "guest" + permissions: + general.csv_export: "yes" + state: "present" + register: __checkmk_var_role_revert_guest + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify guest role was reverted." + ansible.builtin.assert: + that: + - __checkmk_var_role_revert_guest.changed == true + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Try to delete a built-in role (should fail)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "guest" + state: "absent" + register: __checkmk_var_role_delete_builtin + ignore_errors: true + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify built-in role deletion fails." + ansible.builtin.assert: + that: + - __checkmk_var_role_delete_builtin.failed == true + - "'cannot be deleted' in __checkmk_var_role_delete_builtin.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Try to create a custom role without based_on (should fail)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "nonexistent_role" + title: "This Should Fail" + state: "present" + register: __checkmk_var_role_create_no_basedon + ignore_errors: true + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify creation without based_on fails." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_no_basedon.failed == true + - "'based_on' in __checkmk_var_role_create_no_basedon.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete a custom role." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_custom_role" + state: "absent" + register: __checkmk_var_role_delete + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role deletion." + ansible.builtin.assert: + that: + - __checkmk_var_role_delete.changed == true + - "'Role deleted.' in __checkmk_var_role_delete.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete the same custom role again (idempotency)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_custom_role" + state: "absent" + register: __checkmk_var_role_delete_again + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify deletion idempotency." + ansible.builtin.assert: + that: + - __checkmk_var_role_delete_again.changed == false + - __checkmk_var_role_delete_again.msg == "Role already absent." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete the role with permissions (cleanup)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_role_with_perms" + state: "absent" + register: __checkmk_var_role_cleanup + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify cleanup deletion." + ansible.builtin.assert: + that: + - __checkmk_var_role_cleanup.changed == true + - "'Role deleted.' in __checkmk_var_role_cleanup.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role based on admin." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_admin_role" + title: "Custom Admin Role" + based_on: "admin" + state: "present" + register: __checkmk_var_role_create_admin + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify admin-based role creation." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_admin.changed == true + - "'Role created.' in __checkmk_var_role_create_admin.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete the admin-based custom role (cleanup)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_admin_role" + state: "absent" + register: __checkmk_var_role_delete_admin + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify admin-based role was deleted." + ansible.builtin.assert: + that: + - __checkmk_var_role_delete_admin.changed == true + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Test check_mode for role creation." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_role" + title: "Check Mode Role" + based_on: "user" + state: "present" + check_mode: true + register: __checkmk_var_role_checkmode_create + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify check_mode for creation." + ansible.builtin.assert: + that: + - __checkmk_var_role_checkmode_create.changed == true + - __checkmk_var_role_checkmode_create.msg == "Role would be created." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role was not actually created in check_mode." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_role" + state: "absent" + register: __checkmk_var_role_verify_checkmode_create + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role does not exist after check_mode." + ansible.builtin.assert: + that: + - __checkmk_var_role_verify_checkmode_create.changed == false + - __checkmk_var_role_verify_checkmode_create.msg == "Role already absent." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a role for check_mode update and delete tests." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_ops" + title: "Check Mode Ops Role" + based_on: "user" + state: "present" + register: __checkmk_var_role_create_checkmode_ops + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify check_mode ops role was created." + ansible.builtin.assert: + that: + - __checkmk_var_role_create_checkmode_ops.changed == true + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Test check_mode for role update." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_ops" + title: "Would Be Updated Title" + state: "present" + check_mode: true + register: __checkmk_var_role_checkmode_update + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify check_mode for update." + ansible.builtin.assert: + that: + - __checkmk_var_role_checkmode_update.changed == true + - __checkmk_var_role_checkmode_update.msg == "Role would be updated." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role was not actually updated in check_mode." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_ops" + title: "Check Mode Ops Role" + state: "present" + register: __checkmk_var_role_verify_checkmode_update + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify title unchanged after check_mode update." + ansible.builtin.assert: + that: + - __checkmk_var_role_verify_checkmode_update.changed == false + - __checkmk_var_role_verify_checkmode_update.msg == "Role already exists with desired state." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Test check_mode for role deletion." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_ops" + state: "absent" + check_mode: true + register: __checkmk_var_role_checkmode_delete + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify check_mode for deletion." + ansible.builtin.assert: + that: + - __checkmk_var_role_checkmode_delete.changed == true + - __checkmk_var_role_checkmode_delete.msg == "Role would be deleted." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete check_mode ops role (cleanup)." + role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "test_checkmode_ops" + state: "absent" + register: __checkmk_var_role_cleanup_checkmode + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify role existed after check_mode delete." + ansible.builtin.assert: + that: + - __checkmk_var_role_cleanup_checkmode.changed == true + - "'Role deleted.' in __checkmk_var_role_cleanup_checkmode.msg" diff --git a/tests/integration/targets/role/vars/main.yml b/tests/integration/targets/role/vars/main.yml new file mode 100644 index 000000000..7dbdddd3f --- /dev/null +++ b/tests/integration/targets/role/vars/main.yml @@ -0,0 +1,5 @@ +--- +checkmk_var_test_sites: + - version: "{{ checkmk_var_version }}" + edition: "{{ checkmk_var_edition }}" + site: "testsite" diff --git a/tests/unit/plugins/modules/test_role.py b/tests/unit/plugins/modules/test_role.py new file mode 100644 index 000000000..ca3730e25 --- /dev/null +++ b/tests/unit/plugins/modules/test_role.py @@ -0,0 +1,758 @@ +#!/usr/bin/env python +# -*- encoding: utf-8; py-indent-offset: 4 -*- + +# Copyright: (c) 2025, Checkmk GmbH +# 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 + +import json +from unittest.mock import MagicMock, patch + + +from ansible_collections.checkmk.general.plugins.module_utils.types import RESULT +from ansible_collections.checkmk.general.plugins.modules.role import ( + BUILTIN_ROLES, + VALID_PERMISSION_VALUES, + RoleAPI, + run_module, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +COMMON_PARAMS = { + "server_url": "http://localhost/", + "site": "mysite", + "api_user": "cmkadmin", + "api_secret": "mysecret", + "validate_certs": True, +} + +ROLE_GET_RESPONSE_200 = RESULT( + http_code=200, + msg="Role found, nothing changed.", + content=json.dumps( + { + "id": "test_role", + "extensions": { + "alias": "Test Role", + "permissions": ["general.use"], + "builtin": False, + "basedon": "user", + }, + } + ).encode("utf-8"), + etag="", + failed=False, + changed=False, +) + +ROLE_GET_RESPONSE_404 = RESULT( + http_code=404, + msg="Role not found.", + content={}, + etag="", + failed=False, + changed=False, +) + +ROLE_CREATE_RESPONSE = RESULT( + http_code=200, + msg="Role created.", + content={}, + etag="", + failed=False, + changed=True, +) + +ROLE_EDIT_RESPONSE = RESULT( + http_code=200, + msg="Role updated.", + content={}, + etag="", + failed=False, + changed=True, +) + +ROLE_DELETE_RESPONSE = RESULT( + http_code=204, + msg="Role deleted.", + content={}, + etag="", + failed=False, + changed=True, +) + + +def _make_module_params(**overrides): + params = dict(COMMON_PARAMS) + params.update( + { + "name": "test_role", + "title": None, + "based_on": None, + "permissions": None, + "state": "present", + } + ) + params.update(overrides) + return params + + +def _mock_role_api(module_params, current_result): + mock_module = MagicMock() + mock_module.params = module_params + mock_module.check_mode = False + + with patch.object(RoleAPI, "__init__", lambda self, mod: None): + api = RoleAPI.__new__(RoleAPI) + api.module = mock_module + api.params = module_params + api.state = module_params["state"] + api.name = module_params["name"] + api.title = module_params.get("title") + api.based_on = module_params.get("based_on") + api.permissions = module_params.get("permissions") + api.current = current_result + api.headers = {} + + return api + + +# --------------------------------------------------------------------------- +# Tests for constants +# --------------------------------------------------------------------------- + + +class TestConstants: + def test_builtin_roles_contains_expected(self): + assert "admin" in BUILTIN_ROLES + assert "user" in BUILTIN_ROLES + assert "guest" in BUILTIN_ROLES + assert "agent_registration" in BUILTIN_ROLES + + def test_builtin_roles_length(self): + assert len(BUILTIN_ROLES) == 4 + + def test_valid_permission_values(self): + assert VALID_PERMISSION_VALUES == frozenset({"yes", "no", "default"}) + + +# --------------------------------------------------------------------------- +# Tests for RoleAPI._needs_update +# --------------------------------------------------------------------------- + + +class TestNeedsUpdate: + def test_no_update_when_role_not_found(self): + params = _make_module_params(title="New Title") + api = _mock_role_api(params, ROLE_GET_RESPONSE_404) + assert api._needs_update() is False + + def test_no_update_when_nothing_specified(self): + params = _make_module_params() + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is False + + def test_update_needed_when_title_differs(self): + params = _make_module_params(title="Different Title") + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is True + + def test_no_update_when_title_matches(self): + params = _make_module_params(title="Test Role") + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is False + + def test_update_needed_when_permission_value_differs(self): + params = _make_module_params( + permissions={ + "wato.edit": "yes" + } # wato.edit absent from enabled list → needs enabling + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is True + + def test_no_update_when_permission_matches(self): + params = _make_module_params( + permissions={ + "wato.edit": "no" + } # wato.edit absent from enabled list → already disabled + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is False + + def test_update_needed_when_new_permission_added(self): + params = _make_module_params( + permissions={"wato.all_folders": "yes"} # not in current + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is True + + def test_no_update_when_default_matches_base(self): + # general.use is in both base and current perms -> "default" = no change + params = _make_module_params(permissions={"general.use": "default"}) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + with patch.object( + api, "_get_base_role_permissions", return_value={"general.use"} + ) as mock_get_base: + assert api._needs_update() is False + mock_get_base.assert_called_once_with("user") + + def test_update_needed_when_default_differs_from_base(self): + # wato.edit in base but NOT in current -> "default" should restore it -> update needed + params = _make_module_params(permissions={"wato.edit": "default"}) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + with patch.object( + api, "_get_base_role_permissions", return_value={"wato.edit"} + ) as mock_get_base: + assert api._needs_update() is True + mock_get_base.assert_called_once_with("user") + + def test_update_needed_when_both_title_and_permissions_differ(self): + params = _make_module_params( + title="Changed Title", + permissions={"wato.edit": "yes"}, + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is True + + def test_no_update_when_title_and_permissions_match(self): + params = _make_module_params( + title="Test Role", + permissions={ + "general.use": "yes", + "wato.edit": "no", + }, + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._needs_update() is False + + +# --------------------------------------------------------------------------- +# Tests for RoleAPI._build_edit_data +# --------------------------------------------------------------------------- + + +class TestBuildEditData: + def test_empty_when_nothing_set(self): + params = _make_module_params() + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + assert api._build_edit_data() == {} + + def test_includes_alias_when_title_set(self): + params = _make_module_params(title="New Title") + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + data = api._build_edit_data() + assert data == {"new_alias": "New Title"} + + def test_includes_permissions_when_set(self): + perms = {"wato.edit": "yes", "wato.all_folders": "no"} + params = _make_module_params(permissions=perms) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + data = api._build_edit_data() + assert data == {"new_permissions": perms} + + def test_includes_both_when_both_set(self): + perms = {"wato.edit": "yes"} + params = _make_module_params(title="My Title", permissions=perms) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + data = api._build_edit_data() + assert data == {"new_alias": "My Title", "new_permissions": perms} + + +# --------------------------------------------------------------------------- +# Tests for RoleAPI.create +# --------------------------------------------------------------------------- + + +class TestCreate: + def test_create_sends_correct_data(self): + params = _make_module_params( + name="new_role", + title="New Role", + based_on="user", + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_404) + api._fetch = MagicMock(return_value=ROLE_CREATE_RESPONSE) + + result = api.create() + + api._fetch.assert_called_once() + call_kwargs = api._fetch.call_args + data = call_kwargs.kwargs.get("data") or call_kwargs[1].get("data") + assert data["role_id"] == "user" + assert data["new_role_id"] == "new_role" + assert data["new_alias"] == "New Role" + assert "permissions" not in data + assert result.changed is True + + def test_create_without_optional_fields(self): + params = _make_module_params( + name="minimal_role", + based_on="guest", + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_404) + api._fetch = MagicMock(return_value=ROLE_CREATE_RESPONSE) + + api.create() + + call_kwargs = api._fetch.call_args + data = call_kwargs.kwargs.get("data") or call_kwargs[1].get("data") + assert data["role_id"] == "guest" + assert data["new_role_id"] == "minimal_role" + assert "new_alias" not in data + assert "permissions" not in data + + +# --------------------------------------------------------------------------- +# Tests for RoleAPI.edit +# --------------------------------------------------------------------------- + + +class TestEdit: + def test_edit_returns_no_change_when_no_data(self): + params = _make_module_params() + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + + result = api.edit() + + assert result.changed is False + assert result.msg == "Role already up to date." + + def test_edit_sends_correct_data(self): + params = _make_module_params( + title="Updated Title", + permissions={"wato.all_folders": "yes"}, + ) + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + api._fetch = MagicMock(return_value=ROLE_EDIT_RESPONSE) + + result = api.edit() + + api._fetch.assert_called_once() + call_kwargs = api._fetch.call_args + data = call_kwargs.kwargs.get("data") or call_kwargs[1].get("data") + assert data["new_alias"] == "Updated Title" + assert data["new_permissions"] == {"wato.all_folders": "yes"} + assert result.changed is True + + +# --------------------------------------------------------------------------- +# Tests for RoleAPI.delete +# --------------------------------------------------------------------------- + + +class TestDelete: + def test_delete_calls_correct_endpoint(self): + params = _make_module_params(name="test_role", state="absent") + api = _mock_role_api(params, ROLE_GET_RESPONSE_200) + api._fetch = MagicMock(return_value=ROLE_DELETE_RESPONSE) + + result = api.delete() + + api._fetch.assert_called_once() + call_kwargs = api._fetch.call_args + endpoint = call_kwargs.kwargs.get("endpoint") or call_kwargs[1].get("endpoint") + assert "test_role" in endpoint + method = call_kwargs.kwargs.get("method") or call_kwargs[1].get("method") + assert method == "DELETE" + assert result.changed is True + + +# --------------------------------------------------------------------------- +# Tests for run_module (integration of the logic) +# --------------------------------------------------------------------------- + + +class TestRunModule: + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_present_creates_new_role(self, mock_ansible_module_cls, mock_role_api_cls): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", title="New", based_on="user" + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_api.based_on = "user" + mock_api.permissions = None + mock_api.create.return_value = ROLE_CREATE_RESPONSE + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.create.assert_called_once() + mock_api.edit.assert_not_called() + mock_module.exit_json.assert_called_once() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_present_creates_role_with_permissions( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", + title="New", + based_on="user", + permissions={"wato.all_folders": "yes"}, + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_api.based_on = "user" + mock_api.permissions = {"wato.all_folders": "yes"} + mock_api.create.return_value = ROLE_CREATE_RESPONSE + mock_api.edit.return_value = ROLE_EDIT_RESPONSE + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.create.assert_called_once() + mock_api.edit.assert_called_once() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is True + assert call_kwargs["msg"] == "Role created." + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_title_nulled_before_postcreate_edit( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", + title="New", + based_on="user", + permissions={"wato.all_folders": "yes"}, + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_api.based_on = "user" + mock_api.permissions = {"wato.all_folders": "yes"} + mock_api.title = "New" + mock_api.create.return_value = ROLE_CREATE_RESPONSE + + captured_title = [] + + def capture_title(): + captured_title.append(mock_api.title) + return ROLE_EDIT_RESPONSE + + mock_api.edit.side_effect = capture_title + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.edit.assert_called_once() + assert captured_title[0] is None + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_postcreate_edit_failure_calls_fail_json( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", + based_on="user", + permissions={"wato.all_folders": "yes"}, + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + failed_edit = RESULT( + http_code=400, + msg="Bad request.", + content={}, + etag="", + failed=True, + changed=False, + ) + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_api.based_on = "user" + mock_api.permissions = {"wato.all_folders": "yes"} + mock_api.title = None + mock_api.create.return_value = ROLE_CREATE_RESPONSE + mock_api.edit.return_value = failed_edit + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_module.fail_json.assert_called_once() + fail_kwargs = mock_module.fail_json.call_args[1] + assert fail_kwargs["msg"] == "Bad request." + mock_module.exit_json.assert_not_called() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_present_updates_existing_role( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="test_role", title="Changed") + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "test_role" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_api._needs_update.return_value = True + mock_api.edit.return_value = ROLE_EDIT_RESPONSE + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.edit.assert_called_once() + mock_module.exit_json.assert_called_once() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_present_no_change_when_up_to_date( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="test_role", title="Test Role") + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "test_role" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_api._needs_update.return_value = False + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.create.assert_not_called() + mock_api.edit.assert_not_called() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is False + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_present_fails_without_based_on( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", title="New", based_on=None + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.based_on = None + mock_api.current = ROLE_GET_RESPONSE_404 + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_module.fail_json.assert_called_once() + fail_kwargs = mock_module.fail_json.call_args[1] + assert "based_on" in fail_kwargs["msg"] + mock_api.create.assert_not_called() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_absent_deletes_custom_role( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="test_role", state="absent") + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "absent" + mock_api.name = "test_role" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_api.delete.return_value = ROLE_DELETE_RESPONSE + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.delete.assert_called_once() + mock_module.exit_json.assert_called_once() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_absent_fails_for_builtin_role( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="admin", state="absent") + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "absent" + mock_api.name = "admin" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_module.fail_json.assert_called_once() + fail_kwargs = mock_module.fail_json.call_args[1] + assert "cannot be deleted" in fail_kwargs["msg"] + mock_api.delete.assert_not_called() + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_absent_no_change_when_already_absent( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="gone_role", state="absent") + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "absent" + mock_api.name = "gone_role" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.delete.assert_not_called() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is False + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_check_mode_create(self, mock_ansible_module_cls, mock_role_api_cls): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="new_role", title="New", based_on="user" + ) + mock_module.check_mode = True + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "new_role" + mock_api.based_on = "user" + mock_api.current = ROLE_GET_RESPONSE_404 + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.create.assert_not_called() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is True + assert call_kwargs["msg"] == "Role would be created." + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_check_mode_update(self, mock_ansible_module_cls, mock_role_api_cls): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="test_role", title="Changed") + mock_module.check_mode = True + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "present" + mock_api.name = "test_role" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_api._needs_update.return_value = True + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.edit.assert_not_called() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is True + assert call_kwargs["msg"] == "Role would be updated." + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_invalid_permission_value_fails( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params(permissions={"wato.edit": "maybe"}) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + run_module() + + mock_module.fail_json.assert_called_once() + fail_kwargs = mock_module.fail_json.call_args[1] + assert "maybe" in fail_kwargs["msg"] + assert "wato.edit" in fail_kwargs["msg"] + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_default_permission_on_builtin_role_fails( + self, mock_ansible_module_cls, mock_role_api_cls + ): + mock_module = MagicMock() + mock_module.params = _make_module_params( + name="guest", + permissions={"general.csv_export": "default"}, + ) + mock_module.check_mode = False + mock_ansible_module_cls.return_value = mock_module + + run_module() + + mock_module.fail_json.assert_called_once() + fail_kwargs = mock_module.fail_json.call_args[1] + assert "default" in fail_kwargs["msg"] + assert "guest" in fail_kwargs["msg"] + + @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") + @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") + def test_check_mode_delete(self, mock_ansible_module_cls, mock_role_api_cls): + mock_module = MagicMock() + mock_module.params = _make_module_params(name="test_role", state="absent") + mock_module.check_mode = True + mock_ansible_module_cls.return_value = mock_module + + mock_api = MagicMock() + mock_api.state = "absent" + mock_api.name = "test_role" + mock_api.current = ROLE_GET_RESPONSE_200 + mock_role_api_cls.return_value = mock_api + + run_module() + + mock_api.delete.assert_not_called() + mock_module.exit_json.assert_called_once() + call_kwargs = mock_module.exit_json.call_args[1] + assert call_kwargs["changed"] is True + assert call_kwargs["msg"] == "Role would be deleted." From 7d3d65dfbac295fa3f2041b88c589b9e3461ecfd Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Thu, 30 Apr 2026 14:54:45 +0200 Subject: [PATCH 2/7] Add changelog. --- changelogs/fragments/role.yml | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 changelogs/fragments/role.yml diff --git a/changelogs/fragments/role.yml b/changelogs/fragments/role.yml new file mode 100644 index 000000000..43adb1619 --- /dev/null +++ b/changelogs/fragments/role.yml @@ -0,0 +1,2 @@ +major_changes: + - Role module - Add module to manage roles and permissions. From 115ed75735c3eaeb8ced7f4d8495e079721b91d2 Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Thu, 30 Apr 2026 14:54:56 +0200 Subject: [PATCH 3/7] Update meta data. --- .github/labels-issues.yml | 3 +++ .github/labels-prs.yml | 5 +++++ meta/runtime.yml | 1 + 3 files changed, 9 insertions(+) diff --git a/.github/labels-issues.yml b/.github/labels-issues.yml index 2058713af..d06b04437 100644 --- a/.github/labels-issues.yml +++ b/.github/labels-issues.yml @@ -50,6 +50,9 @@ module:notification: module:password: - 'Component Name: password' +module:role: + - 'Component Name: role' + module:rule: - 'Component Name: rule' diff --git a/.github/labels-prs.yml b/.github/labels-prs.yml index 965c0a896..fb639af65 100644 --- a/.github/labels-prs.yml +++ b/.github/labels-prs.yml @@ -79,6 +79,11 @@ module:password: - changed-files: - any-glob-to-any-file: 'plugins/modules/password.py' +module:role: + - any: + - changed-files: + - any-glob-to-any-file: 'plugins/modules/role.py' + module:rule: - any: - changed-files: diff --git a/meta/runtime.yml b/meta/runtime.yml index eb2f577c1..6e8cb9f01 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -14,6 +14,7 @@ action_groups: - host - notification - password + - role - rule - service_group - site From 3d5f9d83125d9e83387f4638df509188957e045b Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Tue, 19 May 2026 10:39:47 +0200 Subject: [PATCH 4/7] Finishing touches. --- .github/workflows/ans-int-test-role.yaml | 12 ++--- plugins/modules/role.py | 56 ++++++++++++++-------- tests/unit/plugins/modules/test_role.py | 59 ++++++------------------ 3 files changed, 56 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ans-int-test-role.yaml b/.github/workflows/ans-int-test-role.yaml index 24cf3296f..faa7f3aee 100644 --- a/.github/workflows/ans-int-test-role.yaml +++ b/.github/workflows/ans-int-test-role.yaml @@ -41,8 +41,8 @@ jobs: matrix: checkmk_version: - 2.3.0p46 - - 2.4.0p26 - - 2.5.0 + - 2.4.0p29 + - 2.5.0p2 checkmk_edition: - raw - managed @@ -53,11 +53,11 @@ jobs: checkmk_edition: community - checkmk_version: 2.3.0p46 checkmk_edition: ultimatemt - - checkmk_version: 2.4.0p26 + - checkmk_version: 2.4.0p29 checkmk_edition: community - - checkmk_version: 2.4.0p26 + - checkmk_version: 2.4.0p29 checkmk_edition: ultimatemt - - checkmk_version: 2.5.0 + - checkmk_version: 2.5.0p2 checkmk_edition: raw - - checkmk_version: 2.5.0 + - checkmk_version: 2.5.0p2 checkmk_edition: managed diff --git a/plugins/modules/role.py b/plugins/modules/role.py index 59762a9be..30d71a04d 100644 --- a/plugins/modules/role.py +++ b/plugins/modules/role.py @@ -13,7 +13,7 @@ --- module: role -short_description: Manage roles in Checkmk. +short_description: Manage roles in Checkmk version_added: "7.5.0" @@ -81,7 +81,7 @@ choices: ["present", "absent"] author: - - "Checkmk GmbH (@checkmk)" + - "Robin Gierse (@robin-checkmk)" notes: - "Idempotency: This module compares the desired configuration @@ -91,6 +91,7 @@ seealso: - module: checkmk.general.user + - module: checkmk.general.contact_group - name: "Checkmk documentation on roles" description: "Complete documentation for user roles and permissions." link: "https://docs.checkmk.com/latest/en/wato_user.html" @@ -100,7 +101,7 @@ # Create a custom role based on the "user" role. - name: "Create a custom monitoring role." checkmk.general.role: - server_url: "http://myserver/" + server_url: "https://myserver/" site: "mysite" api_user: "myuser" api_secret: "mysecret" @@ -112,7 +113,7 @@ # Create a custom role with specific permissions. - name: "Create a custom role with tailored permissions." checkmk.general.role: - server_url: "http://myserver/" + server_url: "https://myserver/" site: "mysite" api_user: "myuser" api_secret: "mysecret" @@ -129,7 +130,7 @@ # Update permissions on an existing role. - name: "Update permissions on an existing custom role." checkmk.general.role: - server_url: "http://myserver/" + server_url: "https://myserver/" site: "mysite" api_user: "myuser" api_secret: "mysecret" @@ -141,7 +142,7 @@ # Update permissions on a built-in role. - name: "Modify permissions on the built-in user role." checkmk.general.role: - server_url: "http://myserver/" + server_url: "https://myserver/" site: "mysite" api_user: "myuser" api_secret: "mysecret" @@ -153,12 +154,34 @@ # Delete a custom role. - name: "Delete a custom role." checkmk.general.role: - server_url: "http://myserver/" + server_url: "https://myserver/" site: "mysite" api_user: "myuser" api_secret: "mysecret" name: "limited_user" state: "absent" + +# --------------------------------------------------------------------------- +# Using environment variables for authentication +# --------------------------------------------------------------------------- +# Connection parameters can be provided via environment variables instead of +# task parameters. The supported variables are: +# CHECKMK_VAR_SERVER_URL, CHECKMK_VAR_SITE, +# CHECKMK_VAR_API_USER, CHECKMK_VAR_API_SECRET, +# CHECKMK_VAR_VALIDATE_CERTS + +- name: "Create a custom role using environment variables for authentication." + checkmk.general.role: + name: "limited_user" + title: "Limited Monitoring User" + based_on: "user" + state: "present" + environment: + CHECKMK_VAR_SERVER_URL: "https://myserver/" + CHECKMK_VAR_SITE: "mysite" + CHECKMK_VAR_API_USER: "myuser" + CHECKMK_VAR_API_SECRET: "mysecret" + CHECKMK_VAR_VALIDATE_CERTS: "true" """ RETURN = r""" @@ -185,7 +208,7 @@ from ansible_collections.checkmk.general.plugins.module_utils.types import RESULT from ansible_collections.checkmk.general.plugins.module_utils.utils import ( base_argument_spec, - result_as_dict, + exit_module, ) BUILTIN_ROLES = ("admin", "user", "guest", "agent_registration") @@ -246,8 +269,6 @@ def _get_base_role_permissions(self, based_on): endpoint="/objects/user_role/%s" % based_on, method="GET", ) - if result.http_code != 200: - return set() content = json.loads(result.content.decode("utf-8")) return set(content.get("extensions", {}).get("permissions", [])) @@ -341,7 +362,7 @@ def run_module(): title=dict(type="str", aliases=["alias"]), based_on=dict( type="str", - choices=["admin", "user", "guest", "agent_registration"], + choices=list(BUILTIN_ROLES), ), permissions=dict(type="dict"), state=dict( @@ -366,14 +387,12 @@ def run_module(): msg="Invalid permission value '%s' for '%s'. Must be one of: %s." % (value, perm, ", ".join(sorted(VALID_PERMISSION_VALUES))) ) - return if name in BUILTIN_ROLES and value == "default": module.fail_json( msg="Permission value 'default' is not valid for built-in role '%s'. " "Built-in roles have no base role; use 'yes' or 'no' explicitly." % name ) - return role = RoleAPI(module) result = RESULT( @@ -409,21 +428,19 @@ def run_module(): changed=False, ) elif role.current.http_code == 404: + # based_on is only required for creation; on update it is ignored, + # so this check must run after the GET disambiguates the state. if role.based_on is None: module.fail_json( msg="'based_on' is required when creating a new custom role." ) - return if not module.check_mode: result = role.create() # Permissions cannot be set during create; apply via PUT if needed. # Null title so the follow-up edit only sends new_permissions. if not result.failed and role.permissions is not None: role.title = None - edit_result = role.edit() - if edit_result.failed: - module.fail_json(msg=edit_result.msg) - return + role.edit() else: result = RESULT( http_code=200, @@ -440,7 +457,6 @@ def run_module(): module.fail_json( msg="Built-in role '%s' cannot be deleted." % role.name ) - return if not module.check_mode: result = role.delete() else: @@ -462,7 +478,7 @@ def run_module(): changed=False, ) - module.exit_json(**result_as_dict(result)) + exit_module(module, result=result) def main(): diff --git a/tests/unit/plugins/modules/test_role.py b/tests/unit/plugins/modules/test_role.py index ca3730e25..f7993895a 100644 --- a/tests/unit/plugins/modules/test_role.py +++ b/tests/unit/plugins/modules/test_role.py @@ -12,6 +12,7 @@ import json from unittest.mock import MagicMock, patch +import pytest from ansible_collections.checkmk.general.plugins.module_utils.types import RESULT from ansible_collections.checkmk.general.plugins.modules.role import ( @@ -26,7 +27,7 @@ # --------------------------------------------------------------------------- COMMON_PARAMS = { - "server_url": "http://localhost/", + "server_url": "https://localhost/", "site": "mysite", "api_user": "cmkadmin", "api_secret": "mysecret", @@ -468,46 +469,6 @@ def capture_title(): mock_api.edit.assert_called_once() assert captured_title[0] is None - @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") - @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") - def test_postcreate_edit_failure_calls_fail_json( - self, mock_ansible_module_cls, mock_role_api_cls - ): - mock_module = MagicMock() - mock_module.params = _make_module_params( - name="new_role", - based_on="user", - permissions={"wato.all_folders": "yes"}, - ) - mock_module.check_mode = False - mock_ansible_module_cls.return_value = mock_module - - failed_edit = RESULT( - http_code=400, - msg="Bad request.", - content={}, - etag="", - failed=True, - changed=False, - ) - mock_api = MagicMock() - mock_api.state = "present" - mock_api.name = "new_role" - mock_api.current = ROLE_GET_RESPONSE_404 - mock_api.based_on = "user" - mock_api.permissions = {"wato.all_folders": "yes"} - mock_api.title = None - mock_api.create.return_value = ROLE_CREATE_RESPONSE - mock_api.edit.return_value = failed_edit - mock_role_api_cls.return_value = mock_api - - run_module() - - mock_module.fail_json.assert_called_once() - fail_kwargs = mock_module.fail_json.call_args[1] - assert fail_kwargs["msg"] == "Bad request." - mock_module.exit_json.assert_not_called() - @patch("ansible_collections.checkmk.general.plugins.modules.role.RoleAPI") @patch("ansible_collections.checkmk.general.plugins.modules.role.AnsibleModule") def test_present_updates_existing_role( @@ -566,6 +527,7 @@ def test_present_fails_without_based_on( name="new_role", title="New", based_on=None ) mock_module.check_mode = False + mock_module.fail_json.side_effect = SystemExit mock_ansible_module_cls.return_value = mock_module mock_api = MagicMock() @@ -575,7 +537,8 @@ def test_present_fails_without_based_on( mock_api.current = ROLE_GET_RESPONSE_404 mock_role_api_cls.return_value = mock_api - run_module() + with pytest.raises(SystemExit): + run_module() mock_module.fail_json.assert_called_once() fail_kwargs = mock_module.fail_json.call_args[1] @@ -612,6 +575,7 @@ def test_absent_fails_for_builtin_role( mock_module = MagicMock() mock_module.params = _make_module_params(name="admin", state="absent") mock_module.check_mode = False + mock_module.fail_json.side_effect = SystemExit mock_ansible_module_cls.return_value = mock_module mock_api = MagicMock() @@ -620,7 +584,8 @@ def test_absent_fails_for_builtin_role( mock_api.current = ROLE_GET_RESPONSE_200 mock_role_api_cls.return_value = mock_api - run_module() + with pytest.raises(SystemExit): + run_module() mock_module.fail_json.assert_called_once() fail_kwargs = mock_module.fail_json.call_args[1] @@ -706,9 +671,11 @@ def test_invalid_permission_value_fails( mock_module = MagicMock() mock_module.params = _make_module_params(permissions={"wato.edit": "maybe"}) mock_module.check_mode = False + mock_module.fail_json.side_effect = SystemExit mock_ansible_module_cls.return_value = mock_module - run_module() + with pytest.raises(SystemExit): + run_module() mock_module.fail_json.assert_called_once() fail_kwargs = mock_module.fail_json.call_args[1] @@ -726,9 +693,11 @@ def test_default_permission_on_builtin_role_fails( permissions={"general.csv_export": "default"}, ) mock_module.check_mode = False + mock_module.fail_json.side_effect = SystemExit mock_ansible_module_cls.return_value = mock_module - run_module() + with pytest.raises(SystemExit): + run_module() mock_module.fail_json.assert_called_once() fail_kwargs = mock_module.fail_json.call_args[1] From 471ea09cead780521e3765999385f0481084e765 Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Wed, 20 May 2026 13:18:11 +0200 Subject: [PATCH 5/7] Final cleanups on role module and tests. --- plugins/modules/role.py | 57 +++++++------- tests/integration/targets/role/tasks/test.yml | 75 ++++++++++++------- 2 files changed, 72 insertions(+), 60 deletions(-) diff --git a/plugins/modules/role.py b/plugins/modules/role.py index 30d71a04d..c1efa38e6 100644 --- a/plugins/modules/role.py +++ b/plugins/modules/role.py @@ -1,9 +1,8 @@ #!/usr/bin/python # -*- encoding: utf-8; py-indent-offset: 4 -*- -# Copyright: (c) 2025, Checkmk GmbH -# GNU General Public License v3.0+ -# (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +# Copyright: (c) 2026, Robin Gierse +# 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 @@ -15,14 +14,13 @@ short_description: Manage roles in Checkmk -version_added: "7.5.0" +version_added: "7.7.0" description: - - Manage user roles within Checkmk. Custom roles are created by - cloning an existing built-in role and can then be modified. - - Built-in roles (C(admin), C(user), C(guest), C(agent_registration)) - cannot be created or deleted, but their permissions can be modified - using this module with C(state=present). + - Manage roles within Checkmk. Custom roles are created by + cloning an existing built-in role. + - Built-in roles cannot be created or deleted, + but their permissions can be modified. extends_documentation_fragment: - checkmk.general.common @@ -44,9 +42,7 @@ based_on: description: - The ID of the built-in role to clone from when creating - a new custom role. Valid values are C(admin), C(user), - C(guest), and C(agent_registration). - - Required when creating a new custom role. + a new custom role. - This parameter is ignored when updating an existing role. type: str choices: ["admin", "user", "guest", "agent_registration"] @@ -84,10 +80,7 @@ - "Robin Gierse (@robin-checkmk)" notes: - - "Idempotency: This module compares the desired configuration - against the current state and only makes changes when necessary." - - "Built-in roles (admin, user, guest, agent_registration) cannot - be created or deleted, but their permissions can be updated." + - Built-in roles cannot be created or deleted, but their permissions can be updated. seealso: - module: checkmk.general.user @@ -98,7 +91,10 @@ """ EXAMPLES = r""" -# Create a custom role based on the "user" role. +# --------------------------------------------------------------------------- +# Create and delete roles +# --------------------------------------------------------------------------- + - name: "Create a custom monitoring role." checkmk.general.role: server_url: "https://myserver/" @@ -110,7 +106,6 @@ based_on: "user" state: "present" -# Create a custom role with specific permissions. - name: "Create a custom role with tailored permissions." checkmk.general.role: server_url: "https://myserver/" @@ -127,7 +122,19 @@ general.edit_notifications: "no" state: "present" -# Update permissions on an existing role. +- name: "Delete a custom role." + checkmk.general.role: + server_url: "https://myserver/" + site: "mysite" + api_user: "myuser" + api_secret: "mysecret" + name: "limited_user" + state: "absent" + +# --------------------------------------------------------------------------- +# Update permissions on existing roles +# --------------------------------------------------------------------------- + - name: "Update permissions on an existing custom role." checkmk.general.role: server_url: "https://myserver/" @@ -139,7 +146,6 @@ wato.all_folders: "yes" state: "present" -# Update permissions on a built-in role. - name: "Modify permissions on the built-in user role." checkmk.general.role: server_url: "https://myserver/" @@ -151,16 +157,6 @@ general.edit_notifications: "no" state: "present" -# Delete a custom role. -- name: "Delete a custom role." - checkmk.general.role: - server_url: "https://myserver/" - site: "mysite" - api_user: "myuser" - api_secret: "mysecret" - name: "limited_user" - state: "absent" - # --------------------------------------------------------------------------- # Using environment variables for authentication # --------------------------------------------------------------------------- @@ -200,7 +196,6 @@ import json -# https://docs.ansible.com/ansible/latest/dev_guide/testing/sanity/import.html from ansible.module_utils.basic import AnsibleModule from ansible_collections.checkmk.general.plugins.module_utils.api import ( CheckmkAPI, diff --git a/tests/integration/targets/role/tasks/test.yml b/tests/integration/targets/role/tasks/test.yml index 303a4ee1c..fa3686384 100644 --- a/tests/integration/targets/role/tasks/test.yml +++ b/tests/integration/targets/role/tasks/test.yml @@ -3,7 +3,7 @@ ansible.builtin.set_fact: checkmk_var_customer: "{{ 'provider' if outer_item.edition in ['managed', 'ultimatemt'] else None }}" -- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role based on user." +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role." role: server_url: "{{ checkmk_var_server_url }}" site: "{{ outer_item.site }}" @@ -80,6 +80,16 @@ - __checkmk_var_role_create_with_perms_again.changed == false - __checkmk_var_role_create_with_perms_again.msg == "Role already exists with desired state." +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Activate." + activation: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Set a permission to default on a custom role." role: server_url: "{{ checkmk_var_server_url }}" @@ -151,6 +161,16 @@ - __checkmk_var_role_update_perms.changed == true - "'Role updated.' in __checkmk_var_role_update_perms.msg" +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Activate." + activation: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Modify permissions on the built-in guest role." role: server_url: "{{ checkmk_var_server_url }}" @@ -186,6 +206,16 @@ that: - __checkmk_var_role_revert_guest.changed == true +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Activate." + activation: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Try to delete a built-in role (should fail)." role: server_url: "{{ checkmk_var_server_url }}" @@ -269,38 +299,15 @@ - __checkmk_var_role_cleanup.changed == true - "'Role deleted.' in __checkmk_var_role_cleanup.msg" -- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create a custom role based on admin." - role: +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Activate." + activation: server_url: "{{ checkmk_var_server_url }}" site: "{{ outer_item.site }}" api_user: "{{ checkmk_var_api_user }}" api_secret: "{{ checkmk_var_api_secret }}" - name: "test_admin_role" - title: "Custom Admin Role" - based_on: "admin" - state: "present" - register: __checkmk_var_role_create_admin - -- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify admin-based role creation." - ansible.builtin.assert: - that: - - __checkmk_var_role_create_admin.changed == true - - "'Role created.' in __checkmk_var_role_create_admin.msg" - -- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete the admin-based custom role (cleanup)." - role: - server_url: "{{ checkmk_var_server_url }}" - site: "{{ outer_item.site }}" - api_user: "{{ checkmk_var_api_user }}" - api_secret: "{{ checkmk_var_api_secret }}" - name: "test_admin_role" - state: "absent" - register: __checkmk_var_role_delete_admin - -- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify admin-based role was deleted." - ansible.builtin.assert: - that: - - __checkmk_var_role_delete_admin.changed == true + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Test check_mode for role creation." role: @@ -421,3 +428,13 @@ that: - __checkmk_var_role_cleanup_checkmode.changed == true - "'Role deleted.' in __checkmk_var_role_cleanup_checkmode.msg" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Activate." + activation: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + force_foreign_changes: true + sites: + - "{{ outer_item.site }}" From 505bff66bbea70d63834fb6f8d7ce86e299243eb Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Wed, 20 May 2026 13:25:39 +0200 Subject: [PATCH 6/7] Add lookup plugins for roles. --- .github/labels-issues.yml | 4 + .github/labels-prs.yml | 6 + .github/workflows/ans-int-test-lkp-role.yaml | 65 ++++++++ changelogs/fragments/role.yml | 2 + plugins/lookup/role.py | 134 +++++++++++++++ plugins/lookup/roles.py | 127 +++++++++++++++ .../targets/lookup_role/tasks/main.yml | 35 ++++ .../targets/lookup_role/tasks/test.yml | 153 ++++++++++++++++++ .../targets/lookup_role/vars/main.yml | 13 ++ 9 files changed, 539 insertions(+) create mode 100644 .github/workflows/ans-int-test-lkp-role.yaml create mode 100644 plugins/lookup/role.py create mode 100644 plugins/lookup/roles.py create mode 100644 tests/integration/targets/lookup_role/tasks/main.yml create mode 100644 tests/integration/targets/lookup_role/tasks/test.yml create mode 100644 tests/integration/targets/lookup_role/vars/main.yml diff --git a/.github/labels-issues.yml b/.github/labels-issues.yml index d06b04437..f784b7e6c 100644 --- a/.github/labels-issues.yml +++ b/.github/labels-issues.yml @@ -90,6 +90,10 @@ lookup:ldap_connection: - 'Component Name: lookup_ldap_connection' - 'Component Name: lookup_ldap_connections' +lookup:role: + - 'Component Name: lookup_role' + - 'Component Name: lookup_roles' + lookup:rule: - 'Component Name: lookup_rule' - 'Component Name: lookup_rules' diff --git a/.github/labels-prs.yml b/.github/labels-prs.yml index fb639af65..7ebcd8137 100644 --- a/.github/labels-prs.yml +++ b/.github/labels-prs.yml @@ -143,6 +143,12 @@ lookup:ldap_connection: - any-glob-to-any-file: 'plugins/modules/lookup/ldap_connection.py' - any-glob-to-any-file: 'plugins/modules/lookup/ldap_connections.py' +lookup:role: + - any: + - changed-files: + - any-glob-to-any-file: 'plugins/lookup/role.py' + - any-glob-to-any-file: 'plugins/lookup/roles.py' + lookup:rule: - any: - changed-files: diff --git a/.github/workflows/ans-int-test-lkp-role.yaml b/.github/workflows/ans-int-test-lkp-role.yaml new file mode 100644 index 000000000..4b1ccd32e --- /dev/null +++ b/.github/workflows/ans-int-test-lkp-role.yaml @@ -0,0 +1,65 @@ +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +name: Lookup Module Role Integration + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: '0 0 * * 0' + pull_request: + branches: + - main + - devel + types: + - opened + paths: + - 'plugins/lookup/role.py' + - 'plugins/lookup/roles.py' + push: + paths: + - '.github/workflows/ans-int-test-lkp-role.yaml' + - 'plugins/lookup/role.py' + - 'plugins/lookup/roles.py' + - 'plugins/module_utils/**' + - 'tests/integration/files/includes/**' + - 'tests/integration/targets/lookup_role/**' + +jobs: + + integration: + name: "${{ matrix.checkmk_version }}.${{ matrix.checkmk_edition }}" + uses: ./.github/workflows/_template-ans-int-test.yaml + with: + module: lookup_role + checkmk_version: ${{ matrix.checkmk_version }} + checkmk_edition: ${{ matrix.checkmk_edition }} + strategy: + fail-fast: false + matrix: + checkmk_version: + - 2.3.0p46 + - 2.4.0p29 + - 2.5.0p2 + checkmk_edition: + - raw + - managed + - community + - ultimatemt + exclude: + - checkmk_version: 2.3.0p46 + checkmk_edition: community + - checkmk_version: 2.3.0p46 + checkmk_edition: ultimatemt + - checkmk_version: 2.4.0p29 + checkmk_edition: community + - checkmk_version: 2.4.0p29 + checkmk_edition: ultimatemt + - checkmk_version: 2.5.0p2 + checkmk_edition: raw + - checkmk_version: 2.5.0p2 + checkmk_edition: managed diff --git a/changelogs/fragments/role.yml b/changelogs/fragments/role.yml index 43adb1619..5d9d06713 100644 --- a/changelogs/fragments/role.yml +++ b/changelogs/fragments/role.yml @@ -1,2 +1,4 @@ major_changes: - Role module - Add module to manage roles and permissions. + - Role lookup - Add lookup plugin to fetch a single role's configuration. + - Roles lookup - Add lookup plugin to list all configured roles. diff --git a/plugins/lookup/role.py b/plugins/lookup/role.py new file mode 100644 index 000000000..80189ab50 --- /dev/null +++ b/plugins/lookup/role.py @@ -0,0 +1,134 @@ +# Copyright: (c) 2026, Robin Gierse +# 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 = """ + name: role + author: Robin Gierse (@robin-checkmk) + version_added: "7.7.0" + + short_description: Get the configuration of a role + + description: + - Returns the configuration of a user role, including its + alias, base role, and assigned permissions. + + options: + + _terms: + description: role ID + required: True + + extends_documentation_fragment: [checkmk.general.common_lookup] + + notes: + - Like all lookups, this runs on the Ansible controller and is unaffected by other keywords such as 'become'. + If you need to use different permissions, you must change the command or run Ansible as another user. + - Alternatively, you can use a shell/command task that runs against localhost and registers the result. + - The directory of the play is used as the current working directory. + - It is B(NOT) possible to assign other variables to the variables mentioned in the C(vars) section! + This is a limitation of Ansible itself. + + seealso: + - module: checkmk.general.role + - plugin: checkmk.general.roles + plugin_type: lookup +""" + +EXAMPLES = """ +- name: "Get the configuration of a role." + ansible.builtin.debug: + msg: "Role host_manager: {{ role_config }}" + vars: + role_config: "{{ + lookup('checkmk.general.role', + 'host_manager', + server_url='https://myserver/', + site='mysite', + api_user='myuser', + api_secret='mysecret', + validate_certs=False + ) + }}" + +# --------------------------------------------------------------------------- +# Using variables from inventory +# --------------------------------------------------------------------------- +# Connection parameters can be provided via inventory variables instead of +# lookup parameters. The supported variables are: +# checkmk_var_server_url, checkmk_var_site, +# checkmk_var_api_user, checkmk_var_api_secret, +# checkmk_var_validate_certs + +- name: "Get a role configuration using inventory variables." + ansible.builtin.debug: + msg: "Role host_manager: {{ role_config }}" + vars: + checkmk_var_server_url: "https://myserver/" + checkmk_var_site: "mysite" + checkmk_var_api_user: "myuser" + checkmk_var_api_secret: "mysecret" + checkmk_var_validate_certs: false + role_config: "{{ lookup('checkmk.general.role', 'host_manager') }}" +""" + +RETURN = """ + _list: + description: + - The configuration of a particular role. + type: list + elements: dict +""" + +import json + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible_collections.checkmk.general.plugins.module_utils.lookup_api import ( + CheckMKLookupAPI, +) + + +class LookupModule(LookupBase): + def run(self, terms, variables, **kwargs): + self.set_options(var_options=variables, direct=kwargs) + server_url = self.get_option("server_url") + site = self.get_option("site") + api_auth_type = self.get_option("api_auth_type") or "bearer" + api_auth_cookie = self.get_option("api_auth_cookie") + api_user = self.get_option("api_user") + api_secret = self.get_option("api_secret") + validate_certs = self.get_option("validate_certs") + + site_url = server_url + "/" + site + + api = CheckMKLookupAPI( + site_url=site_url, + api_auth_type=api_auth_type, + api_auth_cookie=api_auth_cookie, + api_user=api_user, + api_secret=api_secret, + validate_certs=validate_certs, + ) + + ret = [] + + for term in terms: + response = json.loads(api.get("/objects/user_role/" + term)) + + if "code" in response: + raise AnsibleError( + "Received error for %s - %s: %s" + % ( + response.get("url", ""), + response.get("code", ""), + response.get("msg", ""), + ) + ) + + ret.append(response.get("extensions", {})) + + return ret diff --git a/plugins/lookup/roles.py b/plugins/lookup/roles.py new file mode 100644 index 000000000..7703c12f3 --- /dev/null +++ b/plugins/lookup/roles.py @@ -0,0 +1,127 @@ +# Copyright: (c) 2026, Robin Gierse +# 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 = """ + name: roles + author: Robin Gierse (@robin-checkmk) + version_added: "7.7.0" + + short_description: Get a list of all roles + + description: + - Returns a list of all user roles and their configuration, + including aliases, base roles, and assigned permissions. + + extends_documentation_fragment: [checkmk.general.common_lookup] + + notes: + - Like all lookups, this runs on the Ansible controller and is unaffected by other keywords such as 'become'. + If you need to use different permissions, you must change the command or run Ansible as another user. + - Alternatively, you can use a shell/command task that runs against localhost and registers the result. + - The directory of the play is used as the current working directory. + - It is B(NOT) possible to assign other variables to the variables mentioned in the C(vars) section! + This is a limitation of Ansible itself. + + seealso: + - module: checkmk.general.role + - plugin: checkmk.general.role + plugin_type: lookup +""" + +EXAMPLES = """ +- name: "Get all configured roles." + ansible.builtin.debug: + msg: "Role {{ item.id }}: {{ item.extensions }}" + loop: "{{ + lookup('checkmk.general.roles', + server_url='https://myserver/', + site='mysite', + api_user='myuser', + api_secret='mysecret', + validate_certs=False + ) + }}" + loop_control: + label: "{{ item.id }}" + +# --------------------------------------------------------------------------- +# Using variables from inventory +# --------------------------------------------------------------------------- +# Connection parameters can be provided via inventory variables instead of +# lookup parameters. The supported variables are: +# checkmk_var_server_url, checkmk_var_site, +# checkmk_var_api_user, checkmk_var_api_secret, +# checkmk_var_validate_certs + +- name: "Get all configured roles using inventory variables." + ansible.builtin.debug: + msg: "Role {{ item.id }}: {{ item.extensions }}" + vars: + checkmk_var_server_url: "https://myserver/" + checkmk_var_site: "mysite" + checkmk_var_api_user: "myuser" + checkmk_var_api_secret: "mysecret" + checkmk_var_validate_certs: false + loop: "{{ lookup('checkmk.general.roles') }}" + loop_control: + label: "{{ item.id }}" +""" + +RETURN = """ + _list: + description: + - A list of all roles and their configuration. + type: list + elements: dict +""" + +import json + +from ansible.errors import AnsibleError +from ansible.plugins.lookup import LookupBase +from ansible_collections.checkmk.general.plugins.module_utils.lookup_api import ( + CheckMKLookupAPI, +) + + +class LookupModule(LookupBase): + def run(self, terms, variables, **kwargs): + self.set_options(var_options=variables, direct=kwargs) + server_url = self.get_option("server_url") + site = self.get_option("site") + api_auth_type = self.get_option("api_auth_type") or "bearer" + api_auth_cookie = self.get_option("api_auth_cookie") + api_user = self.get_option("api_user") + api_secret = self.get_option("api_secret") + validate_certs = self.get_option("validate_certs") + + site_url = server_url + "/" + site + + api = CheckMKLookupAPI( + site_url=site_url, + api_auth_type=api_auth_type, + api_auth_cookie=api_auth_cookie, + api_user=api_user, + api_secret=api_secret, + validate_certs=validate_certs, + ) + + response = json.loads(api.get("/domain-types/user_role/collections/all")) + + if "code" in response: + raise AnsibleError( + "Received error for %s - %s: %s" + % ( + response.get("url", ""), + response.get("code", ""), + response.get("msg", ""), + ) + ) + + role_list = response.get("value") + + return [role_list] diff --git a/tests/integration/targets/lookup_role/tasks/main.yml b/tests/integration/targets/lookup_role/tasks/main.yml new file mode 100644 index 000000000..5ec7ab4db --- /dev/null +++ b/tests/integration/targets/lookup_role/tasks/main.yml @@ -0,0 +1,35 @@ +--- +- name: "Include Global Variables." + ansible.builtin.include_vars: "{{ lookup('ansible.builtin.first_found', checkmk_var_params) }}" + vars: + checkmk_var_params: + files: + - global.yml + paths: + - /home/runner/work/ansible-collection-checkmk.general/ansible-collection-checkmk.general/ansible_collections/checkmk/general/tests/integration/files/includes/vars/ + - /root/ansible_collections/checkmk/general/tests/integration/files/includes/vars/ + - tests/integration/files/includes/vars/ + +- name: "Print Identifier." + ansible.builtin.debug: + msg: "{{ ansible_facts['system_vendor'] }} {{ ansible_facts['product_name'] }} running {{ ansible_facts['virtualization_type'] }}" + +- name: "Run preparations." + ansible.builtin.include_tasks: "{{ lookup('ansible.builtin.first_found', checkmk_var_params) }}" + vars: + checkmk_var_params: + files: + - prep.yml + paths: + - /home/runner/work/ansible-collection-checkmk.general/ansible-collection-checkmk.general/ansible_collections/checkmk/general/tests/integration/files/includes/tasks/ + - /root/ansible_collections/checkmk/general/tests/integration/files/includes/tasks/ + - tests/integration/files/includes/tasks/ + when: + - ansible_facts['virtualization_type'] in ['container', 'podman', 'docker'] + - (ansible_facts['system_vendor'] == "Dell Inc." and 'Latitude' in ansible_facts['product_name']) or (ansible_facts['system_vendor'] == "QEMU" and 'Ubuntu' in ansible_facts['product_name']) + +- name: "Testing." + ansible.builtin.include_tasks: test.yml + loop: "{{ checkmk_var_test_sites }}" + loop_control: + loop_var: outer_item diff --git a/tests/integration/targets/lookup_role/tasks/test.yml b/tests/integration/targets/lookup_role/tasks/test.yml new file mode 100644 index 000000000..d3d916cae --- /dev/null +++ b/tests/integration/targets/lookup_role/tasks/test.yml @@ -0,0 +1,153 @@ +--- +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Set customer attribute." + ansible.builtin.set_fact: + checkmk_var_customer: "{{ 'provider' if outer_item.edition in ['managed', 'ultimatemt'] else None }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Create custom roles for lookup tests." + checkmk.general.role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "{{ item.name }}" + title: "{{ item.title }}" + based_on: "{{ item.based_on }}" + state: "present" + loop: "{{ checkmk_var_roles }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Get all roles." + ansible.builtin.set_fact: + __checkmk_var_roles_list: "{{ lookup('checkmk.general.roles', + server_url=checkmk_var_server_url, + site=outer_item.site, + validate_certs=False, + api_user=checkmk_var_api_user, + api_secret=checkmk_var_api_secret) }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify all built-in roles are returned." + ansible.builtin.assert: + that: "item in (__checkmk_var_roles_list | map(attribute='id') | list)" + fail_msg: "Built-in role '{{ item }}' not found in lookup result!" + success_msg: "Built-in role '{{ item }}' is present." + loop: + - "admin" + - "user" + - "guest" + - "agent_registration" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify custom roles are returned." + ansible.builtin.assert: + that: "item.name in (__checkmk_var_roles_list | map(attribute='id') | list)" + fail_msg: "Custom role '{{ item.name }}' not found in lookup result!" + success_msg: "Custom role '{{ item.name }}' is present." + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify aliases of custom roles via list lookup." + ansible.builtin.assert: + that: "(__checkmk_var_roles_list | selectattr('id', 'equalto', item.name) | map(attribute='extensions.alias') | first) == item.title" + fail_msg: "Expected alias '{{ item.title }}' for role '{{ item.name }}' but got something else." + success_msg: "Alias for role '{{ item.name }}' is '{{ item.title }}' as expected." + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Get a single custom role." + ansible.builtin.set_fact: + __checkmk_var_role: "{{ lookup('checkmk.general.role', + item.name, + server_url=checkmk_var_server_url, + site=outer_item.site, + validate_certs=False, + api_user=checkmk_var_api_user, + api_secret=checkmk_var_api_secret) }}" + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + register: __checkmk_var_role_single_lookup + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify single role lookup matches expected alias." + ansible.builtin.assert: + that: "(lookup('checkmk.general.role', + item.name, + server_url=checkmk_var_server_url, + site=outer_item.site, + validate_certs=False, + api_user=checkmk_var_api_user, + api_secret=checkmk_var_api_secret)).alias == item.title" + fail_msg: "Expected alias '{{ item.title }}' for role '{{ item.name }}', got a mismatch." + success_msg: "Single role lookup of '{{ item.name }}' has alias '{{ item.title }}' as expected." + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify single role lookup reports correct basedon." + ansible.builtin.assert: + that: "(lookup('checkmk.general.role', + item.name, + server_url=checkmk_var_server_url, + site=outer_item.site, + validate_certs=False, + api_user=checkmk_var_api_user, + api_secret=checkmk_var_api_secret)).basedon == item.based_on" + fail_msg: "Expected basedon '{{ item.based_on }}' for role '{{ item.name }}'." + success_msg: "Role '{{ item.name }}' is based on '{{ item.based_on }}' as expected." + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Look up a built-in role." + ansible.builtin.set_fact: + __checkmk_var_builtin_role: "{{ lookup('checkmk.general.role', + 'admin', + server_url=checkmk_var_server_url, + site=outer_item.site, + validate_certs=False, + api_user=checkmk_var_api_user, + api_secret=checkmk_var_api_secret) }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify built-in admin role has permissions." + ansible.builtin.assert: + that: + - "__checkmk_var_builtin_role.permissions is defined" + - "__checkmk_var_builtin_role.permissions | length > 0" + fail_msg: "Built-in admin role should expose a non-empty permissions list." + success_msg: "Built-in admin role exposes {{ __checkmk_var_builtin_role.permissions | length }} permissions." + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Use variables from inventory." + block: + + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Get all roles via inventory variables." + ansible.builtin.set_fact: + __checkmk_var_roles_list: "{{ lookup('checkmk.general.roles') }}" + + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify built-in roles via inventory variables." + ansible.builtin.assert: + that: "item in (__checkmk_var_roles_list | map(attribute='id') | list)" + fail_msg: "Built-in role '{{ item }}' not found via inventory variables!" + success_msg: "Built-in role '{{ item }}' is present." + loop: + - "admin" + - "user" + - "guest" + - "agent_registration" + + - name: "{{ outer_item.version }}.{{ outer_item.edition }} - Verify custom role lookup via inventory variables." + ansible.builtin.assert: + that: "(lookup('checkmk.general.role', item.name)).alias == item.title" + fail_msg: "Expected alias '{{ item.title }}' for role '{{ item.name }}' via inventory variables." + success_msg: "Single role lookup of '{{ item.name }}' via inventory variables has alias '{{ item.title }}'." + loop: "{{ checkmk_var_roles }}" + loop_control: + label: "{{ item.name }}" + +- name: "{{ outer_item.version }}.{{ outer_item.edition }} - Delete custom roles (cleanup)." + checkmk.general.role: + server_url: "{{ checkmk_var_server_url }}" + site: "{{ outer_item.site }}" + api_user: "{{ checkmk_var_api_user }}" + api_secret: "{{ checkmk_var_api_secret }}" + name: "{{ item.name }}" + state: "absent" + loop: "{{ checkmk_var_roles }}" diff --git a/tests/integration/targets/lookup_role/vars/main.yml b/tests/integration/targets/lookup_role/vars/main.yml new file mode 100644 index 000000000..e5ed110d4 --- /dev/null +++ b/tests/integration/targets/lookup_role/vars/main.yml @@ -0,0 +1,13 @@ +--- +checkmk_var_test_sites: + - version: "{{ checkmk_var_version }}" + edition: "{{ checkmk_var_edition }}" + site: "testsite" + +checkmk_var_roles: + - name: "lookup_test_user_role" + title: "Lookup Test User Role" + based_on: "user" + - name: "lookup_test_admin_role" + title: "Lookup Test Admin Role" + based_on: "admin" From e2c40cf19027d3bacbad575897c1ec85f5ce7c04 Mon Sep 17 00:00:00 2001 From: Robin Gierse Date: Wed, 20 May 2026 13:25:47 +0200 Subject: [PATCH 7/7] Update README with new roles. --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index a6d2308c0..77f313919 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Name | Description | Tests [checkmk.general.folders](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/folders.py)|Look up all folders.|[![Integration Tests for Folders Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-folder.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-folder.yaml) [checkmk.general.host](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/host.py)|Look up host attributes.|[![Integration Tests for Host Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-host.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-host.yaml) [checkmk.general.hosts](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/hosts.py)|Look up all hosts.|[![Integration Tests for Hosts Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-host.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-host.yaml) +[checkmk.general.role](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/role.py)|Look up role attributes.|[![Integration Tests for Role Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-role.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-role.yaml) +[checkmk.general.roles](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/roles.py)|Look up all roles.|[![Integration Tests for Roles Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-role.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-role.yaml) [checkmk.general.rule](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/rule.py)|Look up rule attributes.|[![Integration Tests for Rule Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rules.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rules.yaml) [checkmk.general.rules](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/rules.py)|Look up all rules.|[![Integration Tests for Rules Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rules.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rules.yaml) [checkmk.general.ruleset](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/lookup/ruleset.py)|Look up ruleset attributes.|[![Integration Tests for Ruleset Lookup Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rulesets.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-lkp-rulesets.yaml) @@ -85,6 +87,7 @@ Name | Description | Tests [checkmk.general.folder](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/folder.py)|Manage folders.|[![Integration Tests for Folder Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-folder.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-folder.yaml) [checkmk.general.host_group](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/host_group.py)|Manage host groups.|[![Integration Tests for Host Group Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-host_group.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-host_group.yaml) [checkmk.general.host](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/host.py)|Manage hosts.|[![Integration Tests for Host Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-host.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-host.yaml) +[checkmk.general.role](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/role.py)|Manage roles and permissions.|[![Integration Tests for Role Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-role.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-role.yaml) [checkmk.general.rule](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/rule.py)|Manage rules.|[![Integration Tests for Rule Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-rule.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-rule.yaml) [checkmk.general.service_group](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/service_group.py)|Manage service groups.|[![Integration Tests for Service Group Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-service_group.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-service_group.yaml) [checkmk.general.site](https://github.com/Checkmk/ansible-collection-checkmk.general/blob/main/plugins/modules/site.py)|Manage sites.|[![Integration Tests for Site Module](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-site.yaml/badge.svg)](https://github.com/Checkmk/ansible-collection-checkmk.general/actions/workflows/ans-int-test-site.yaml)