From 60f2d004db3e8bbc023dfc3c88431ce1841a541c Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Tue, 19 Aug 2025 12:44:17 -0400 Subject: [PATCH 1/5] [minor_change] Add a new module nd_local_user for local users on Nexus Dashboard v4.1.0 or higher. --- plugins/module_utils/constants.py | 9 + plugins/module_utils/nd.py | 4 + plugins/modules/nd_local_user.py | 274 +++++++++++++++++ .../targets/nd_local_user/tasks/main.yml | 276 ++++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 plugins/modules/nd_local_user.py create mode 100644 tests/integration/targets/nd_local_user/tasks/main.yml diff --git a/plugins/module_utils/constants.py b/plugins/module_utils/constants.py index 5d09e748..02dbba3b 100644 --- a/plugins/module_utils/constants.py +++ b/plugins/module_utils/constants.py @@ -168,3 +168,12 @@ ND_REST_KEYS_TO_SANITIZE = ["metadata"] ND_SETUP_NODE_DEPLOYMENT_TYPE = {"physical": "cimc", "virtual": "vnode"} + +USER_ROLES_MAPPING = { + "fabric_admin": "fabric-admin", + "observer": "observer", + "super_admin": "super-admin", + "support_engineer": "support-engineer", + "approver": "approver", + "designer": "designer", +} diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index cca3ed42..52ab88b7 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -317,6 +317,10 @@ def request( if ignore_not_found_error: return {} self.fail_json(msg="ND Error: {0}".format(payload["errors"][0]), data=data, info=info, payload=payload) + elif "error" in payload and len(payload.get("error")) > 0: + if ignore_not_found_error: + return {} + self.fail_json(msg="ND Error: {0}".format(payload["error"]), data=data, info=info, payload=payload) else: if ignore_not_found_error: return {} diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py new file mode 100644 index 00000000..7572757a --- /dev/null +++ b/plugins/modules/nd_local_user.py @@ -0,0 +1,274 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +ANSIBLE_METADATA = {"metadata_version": "1.1", "status": ["preview"], "supported_by": "community"} + +DOCUMENTATION = r""" +--- +module: nd_local_user +version_added: "1.4.0" +short_description: Manage local users on Nexus Dashboard +description: +- Manage local users on Cisco Nexus Dashboard (ND). +- It supports creating, updating, querying, and deleting local users. +author: +- Gaspard Micol (@gmicol) +options: + email: + description: + - The email address of the local user. + type: str + login_id: + description: + - The login ID of the local user. + type: str + first_name: + description: + - The first name of the local user. + type: str + last_name: + description: + - The last name of the local user. + type: str + user_password: + description: + - The password of the local user. + - Password must have a minimum of 8 characters to a maximum of 64 characters. + - Password must have three of the following; one number, one lower case character, one upper case character, one special character. + type: str + reuse_limitation: + description: + - The number of different passwords a user must use before they can reuse a previous one. + type: int + time_interval_limitation: + description: + - The minimum time period that must pass before a previous password can be reused. + type: int + security_domains: + description: + - The list of Security Domains and Roles for the local user. + type: list + elements: dict + suboptions: + name: + description: + - The name of the Security Domain to which give the local user access. + type: str + required: true + aliases: [ security_domain_name, domain_name ] + roles: + description: + - The Permission Roles of the local user within the Security Domain. + type: list + elements: str + choices: [ fabric_admin, observer, super_admin, support_engineer, approver, designer ] + aliases: [ domains ] + remote_id_claim: + description: + - The remote ID claim of the local user. + type: str + remote_user_authorization: + description: + - To enable/disable the Remote User Authorization of the local user. + - Remote User Authorization is used for signing into Nexus Dashboard when using identity providers that cannot provide authorization claims. + Once this attribute is enabled, the local user ID cannot be used to directly login to Nexus Dashboard. + type: bool + state: + description: + - Use C(present) to create or update a local user. + - Use C(absent) to delete an existing local user. + - Use C(query) for listing all the existing local users or a specific local user if O(login_id) is specified. + type: str + default: present + choices: [ present, absent, query ] +extends_documentation_fragment: +- cisco.nd.modules +- cisco.nd.check_mode +notes: +- This module is only supported on Nexus Dashboard having version 4.1.0 or higher. +- This module is not idempotent when creating or updating a local user object. +""" + +EXAMPLES = r""" +- name: Create a new local user + cisco.nd.nd_local_user: + email: user@example.com + login_id: local_user + first_name: User first name + last_name: User last name + user_password: localUserPassword1% + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + name: all + roles: + - observer + - support_engineer + remote_id_claim: remote_user + remote_user_authorization: true + state: present + register: result + +- name: Create local user with minimal configuration + cisco.nd.nd_local_user: + login_id: local_user_min + user_password: localUserMinuser_password + security_domain: all + state: present + +- name: Update local user + cisco.nd.nd_local_user: + email: udpateduser@example.com + login_id: local_user + first_name: Updated user first name + last_name: Updated user last name + user_password: updatedLocalUserPassword1% + reuse_limitation: 25 + time_interval_limitation: 15 + security_domains: + - name: all + roles: super_admin + - name: ansible_domain + roles: observer + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: present + +- name: Query an existing local user + cisco.nd.nd_local_user: + login_id: local_user + state: query + register: query_result + +- name: Query all local users + cisco.nd.nd_local_user: + state: query + register: all_keys + +- name: Delete an local user + cisco.nd.nd_local_user: + login_id: local_user + state: absent +""" + +RETURN = r""" +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.cisco.nd.plugins.module_utils.nd import NDModule, nd_argument_spec, sanitize_dict +from ansible_collections.cisco.nd.plugins.module_utils.constants import USER_ROLES_MAPPING + + +def main(): + argument_spec = nd_argument_spec() + argument_spec.update( + email=dict(type="str"), + login_id=dict(type="str"), + first_name=dict(type="str"), + last_name=dict(type="str"), + user_password=dict(type="str", no_log=True), + reuse_limitation=dict(type="int"), + time_interval_limitation=dict(type="int"), + security_domains=dict( + type="list", + elements="dict", + options=dict( + name=dict(type="str", required=True, aliases=["security_domain_name", "domain_name"]), + roles=dict(type="list", elements="str", choices=list(USER_ROLES_MAPPING)), + ), + aliases=["domains"], + ), + remote_id_claim=dict(type="str"), + remote_user_authorization=dict(type="bool"), + state=dict(type="str", default="present", choices=["present", "absent", "query"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["state", "present", ["login_id"]], + ["state", "absent", ["login_id"]], + ], + ) + + nd = NDModule(module) + + email = nd.params.get("email") + login_id = nd.params.get("login_id") + first_name = nd.params.get("first_name") + last_name = nd.params.get("last_name") + user_password = nd.params.get("user_password") + reuse_limitation = nd.params.get("reuse_limitation") + time_interval_limitation = nd.params.get("time_interval_limitation") + security_domains = nd.params.get("security_domains") + remote_id_claim = nd.params.get("remote_id_claim") + remote_user_authorization = nd.params.get("remote_user_authorization") + state = nd.params.get("state") + + path = "/api/v1/infra/aaa/localUsers" + if login_id: + updated_path = "{0}/{1}".format(path, login_id) + nd.existing = nd.previous = nd.query_obj(path=updated_path, ignore_not_found_error=True) + else: + nd.existing = nd.query_obj(path=path, ignore_not_found_error=True) + + if state == "present": + + payload = { + "email": email, + "firstName": first_name, + "lastName": last_name, + "loginID": login_id, + "password": user_password, + "remoteIDClaim": remote_id_claim, + "xLaunch": remote_user_authorization, + } + + if security_domains: + payload["rbac"] = { + "domains": { + security_domain.get("name"): { + "roles": [USER_ROLES_MAPPING.get(role) for role in security_domain["roles"]] if isinstance(security_domain.get("roles"), list) else [], + } + for security_domain in security_domains + }, + } + if reuse_limitation or time_interval_limitation: + payload["passwordPolicy"] = sanitize_dict( + { + "reuseLimitation": reuse_limitation, + "timeIntervalLimitation": time_interval_limitation, + } + ) + + nd.sanitize(payload) + + if not module.check_mode: + if nd.existing: + nd.existing = nd.request(path=updated_path, method="PUT", data=payload) + else: + nd.existing = nd.request(path=path, method="POST", data=payload) + else: + nd.existing = nd.proposed + + elif state == "absent": + if nd.existing: + if not module.check_mode: + nd.request(path="{0}/{1}".format(path, login_id), method="DELETE") + nd.existing = {} + + nd.exit_json() + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml new file mode 100644 index 00000000..5bdea3cf --- /dev/null +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -0,0 +1,276 @@ +# Test code for the ND modules +# Copyright: (c) 2025, Gaspard Micol (@gmicol) + +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +- name: Test that we have a Nexus Dashboard host, username and password + ansible.builtin.fail: + msg: 'Please define the following variables: ansible_host, ansible_user and ansible_password.' + when: ansible_host is not defined or ansible_user is not defined or ansible_password is not defined + +- name: Set vars + ansible.builtin.set_fact: + nd_info: &nd_info + output_level: '{{ api_key_output_level | default("debug") }}' + +- name: Ensure local users do not exist before test starts + cisco.nd.nd_local_user: + <<: *nd_info + login_id: "{{ item }}" + state: absent + loop: + - ansible_local_user + - ansible_local_user_2 + +#CREATE +- name: Create local user (check mode) + cisco.nd.nd_local_user: &create_local_user + <<: *nd_info + email: ansibleuser@example.com + login_id: ansible_local_user + first_name: Ansible first name + last_name: Ansible last name + user_password: ansibleLocalUserPassword1% + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + - name: all + roles: + - observer + - support_engineer + remote_id_claim: ansible_remote_user + remote_user_authorization: true + state: present + check_mode: true + register: cm_create_local_user + +- name: Create local user (normal mode) + cisco.nd.nd_local_user: + <<: *create_local_user + register: nm_create_local_user + +- name: Create a second local user with minimum configuration + cisco.nd.nd_local_user: + <<: *nd_info + login_id: ansible_local_user_2 + user_password: ansibleLocalUser2Password1% + security_domains: + - name: all + state: present + register: nm_create_local_user_2 + +- name: Verify local user creation tasks + ansible.builtin.assert: + that: + - cm_create_local_user is changed + - cm_create_local_user.previous == {} + - cm_create_local_user.current == cm_create_local_user.proposed + - cm_create_local_user.current.email == "ansibleuser@example.com" + - cm_create_local_user.current.firstName == "Ansible first name" + - cm_create_local_user.current.lastName == "Ansible last name" + - cm_create_local_user.current.loginID == "ansible_local_user" + - cm_create_local_user.current.passwordPolicy.reuseLimitation == 20 + - cm_create_local_user.current.passwordPolicy.timeIntervalLimitation == 10 + - cm_create_local_user.current.rbac.domains.all.roles.0 == "observer" + - cm_create_local_user.current.rbac.domains.all.roles.1 == "support-engineer" + - cm_create_local_user.current.remoteIDClaim == "ansible_remote_user" + - cm_create_local_user.current.xLaunch == true + - nm_create_local_user is changed + - nm_create_local_user.previous == {} + - nm_create_local_user.proposed.email == "ansibleuser@example.com" + - nm_create_local_user.proposed.firstName == "Ansible first name" + - nm_create_local_user.proposed.lastName == "Ansible last name" + - nm_create_local_user.proposed.loginID == "ansible_local_user" + - nm_create_local_user.proposed.passwordPolicy.reuseLimitation == 20 + - nm_create_local_user.proposed.passwordPolicy.timeIntervalLimitation == 10 + - nm_create_local_user.proposed.rbac.domains.all.roles.0 == "observer" + - nm_create_local_user.proposed.rbac.domains.all.roles.1 == "support-engineer" + - nm_create_local_user.proposed.remoteIDClaim == "ansible_remote_user" + - nm_create_local_user.proposed.xLaunch == true + - nm_create_local_user.current.email == "ansibleuser@example.com" + - nm_create_local_user.current.firstName == "Ansible first name" + - nm_create_local_user.current.lastName == "Ansible last name" + - nm_create_local_user.current.loginID == "ansible_local_user" + - nm_create_local_user.current.passwordPolicy.reuseLimitation == 20 + - nm_create_local_user.current.passwordPolicy.timeIntervalLimitation == 10 + - nm_create_local_user.current.rbac.domains.all.roles.0 == "observer" + - nm_create_local_user.current.rbac.domains.all.roles.1 == "support-engineer" + - nm_create_local_user.current.remoteIDClaim == "ansible_remote_user" + - nm_create_local_user.current.xLaunch == true + - nm_create_local_user_2 is changed + - nm_create_local_user_2.previous == {} + - nm_create_local_user_2.proposed.loginID == "ansible_local_user_2" + - nm_create_local_user_2.proposed.rbac.domains.all.roles == [] + - nm_create_local_user_2.current.loginID == "ansible_local_user_2" + - nm_create_local_user_2.current.rbac.domains.all.roles == [] + +#UPDATE +- name: Update local user name (check mode) + cisco.nd.nd_local_user: &update_local_user + <<: *create_local_user + email: updatedansibleuser@example.com + first_name: Updated Ansible first name + last_name: Updated Ansible last name + user_password: updatedAnsibleLocalUserPassword1% + reuse_limitation: 25 + time_interval_limitation: 15 + security_domains: + - name: all + roles: super_admin + remote_id_claim: "" + remote_user_authorization: false + state: present + check_mode: true + register: cm_update_local_user + +- name: Update local user name (normal mode) + cisco.nd.nd_local_user: + <<: *update_local_user + register: nm_update_local_user + +- name: Verify local user update tasks + ansible.builtin.assert: + that: + - cm_update_local_user is changed + - cm_update_local_user.previous.email == "ansibleuser@example.com" + - cm_update_local_user.previous.firstName == "Ansible first name" + - cm_update_local_user.previous.lastName == "Ansible last name" + - cm_update_local_user.previous.loginID == "ansible_local_user" + - cm_update_local_user.previous.passwordPolicy.reuseLimitation == 20 + - cm_update_local_user.previous.passwordPolicy.timeIntervalLimitation == 10 + - cm_update_local_user.previous.rbac.domains.all.roles.0 == "observer" + - cm_update_local_user.previous.rbac.domains.all.roles.1 == "support-engineer" + - cm_update_local_user.previous.remoteIDClaim == "ansible_remote_user" + - cm_update_local_user.previous.xLaunch == true + - cm_update_local_user.current == cm_update_local_user.proposed + - cm_update_local_user.current.email == "updatedansibleuser@example.com" + - cm_update_local_user.current.firstName == "Updated Ansible first name" + - cm_update_local_user.current.lastName == "Updated Ansible last name" + - cm_update_local_user.current.loginID == "ansible_local_user" + - cm_update_local_user.current.passwordPolicy.reuseLimitation == 25 + - cm_update_local_user.current.passwordPolicy.timeIntervalLimitation == 15 + - cm_update_local_user.current.rbac.domains.all.roles.0 == "super-admin" + - cm_update_local_user.current.remoteIDClaim == "" + - cm_update_local_user.current.xLaunch == false + - nm_update_local_user is changed + - nm_update_local_user.previous.email == "ansibleuser@example.com" + - nm_update_local_user.previous.firstName == "Ansible first name" + - nm_update_local_user.previous.lastName == "Ansible last name" + - nm_update_local_user.previous.loginID == "ansible_local_user" + - nm_update_local_user.previous.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user.previous.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user.previous.rbac.domains.all.roles.0 == "observer" + - nm_update_local_user.previous.rbac.domains.all.roles.1 == "support-engineer" + - nm_update_local_user.previous.remoteIDClaim == "ansible_remote_user" + - nm_update_local_user.previous.xLaunch == true + - nm_update_local_user.proposed.email == "updatedansibleuser@example.com" + - nm_update_local_user.proposed.firstName == "Updated Ansible first name" + - nm_update_local_user.proposed.lastName == "Updated Ansible last name" + - nm_update_local_user.proposed.loginID == "ansible_local_user" + - nm_update_local_user.proposed.passwordPolicy.reuseLimitation == 25 + - nm_update_local_user.proposed.passwordPolicy.timeIntervalLimitation == 15 + - nm_update_local_user.proposed.rbac.domains.all.roles.0 == "super-admin" + - nm_update_local_user.proposed.remoteIDClaim == "" + - nm_update_local_user.proposed.xLaunch == false + - nm_update_local_user.current.email == "updatedansibleuser@example.com" + - nm_update_local_user.current.firstName == "Updated Ansible first name" + - nm_update_local_user.current.lastName == "Updated Ansible last name" + - nm_update_local_user.current.loginID == "ansible_local_user" + - nm_update_local_user.current.passwordPolicy.reuseLimitation == 25 + - nm_update_local_user.current.passwordPolicy.timeIntervalLimitation == 15 + - nm_update_local_user.current.rbac.domains.all.roles.0 == "super-admin" + - nm_update_local_user.current.remoteIDClaim == "" + - nm_update_local_user.current.xLaunch == false + +#QUERY +- name: Query all local users + cisco.nd.nd_local_user: + <<: *nd_info + state: query + register: query_all_local_users + +- name: Query specific local user + cisco.nd.nd_local_user: + <<: *nd_info + login_id: ansible_local_user + state: query + register: query_local_user + +- name: query non-existent local user + cisco.nd.nd_local_user: + <<: *nd_info + login_id: non_existent_local_user + state: query + register: query_non_existent + +- name: Verify local user query tasks + ansible.builtin.assert: + that: + - query_all_local_users is not changed + - query_all_local_users.current.localusers | length >= 2 + - query_local_user is not changed + - query_local_user.current.email == "updatedansibleuser@example.com" + - query_local_user.current.firstName == "Updated Ansible first name" + - query_local_user.current.lastName == "Updated Ansible last name" + - query_local_user.current.loginID == "ansible_local_user" + - query_local_user.current.passwordPolicy.reuseLimitation == 25 + - query_local_user.current.passwordPolicy.timeIntervalLimitation == 15 + - query_local_user.current.rbac.domains.all.roles.0 == "super-admin" + - query_local_user.current.xLaunch == false + - query_non_existent is not changed + - query_non_existent.current == {} + +#DELETE +- name: Delete local user by name (check mode) + cisco.nd.nd_local_user: &delete_local_user + <<: *nd_info + login_id: ansible_local_user + state: absent + check_mode: true + register: cm_delete_local_user + +- name: Delete local user by name (normal mode) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user + +- name: Delete local user again (idempotency test) + cisco.nd.nd_local_user: + <<: *delete_local_user + register: nm_delete_local_user_again + +- name: Verify local user deletion tasks + ansible.builtin.assert: + that: + - cm_delete_local_user is changed + - cm_delete_local_user.previous.email == "updatedansibleuser@example.com" + - cm_delete_local_user.previous.firstName == "Updated Ansible first name" + - cm_delete_local_user.previous.lastName == "Updated Ansible last name" + - cm_delete_local_user.previous.loginID == "ansible_local_user" + - cm_delete_local_user.previous.passwordPolicy.reuseLimitation == 25 + - cm_delete_local_user.previous.passwordPolicy.timeIntervalLimitation == 15 + - cm_delete_local_user.previous.rbac.domains.all.roles.0 == "super-admin" + - cm_delete_local_user.previous.xLaunch == false + - cm_delete_local_user.current == cm_delete_local_user.proposed == {} + - nm_delete_local_user is changed + - nm_delete_local_user.previous.email == "updatedansibleuser@example.com" + - nm_delete_local_user.previous.firstName == "Updated Ansible first name" + - nm_delete_local_user.previous.lastName == "Updated Ansible last name" + - nm_delete_local_user.previous.loginID == "ansible_local_user" + - nm_delete_local_user.previous.passwordPolicy.reuseLimitation == 25 + - nm_delete_local_user.previous.passwordPolicy.timeIntervalLimitation == 15 + - nm_delete_local_user.previous.rbac.domains.all.roles.0 == "super-admin" + - nm_delete_local_user.previous.xLaunch == false + - nm_delete_local_user.current == nm_delete_local_user.proposed == {} + - nm_delete_local_user_again is not changed + - nm_delete_local_user_again.current == nm_delete_local_user_again.proposed == nm_delete_local_user_again.previous == {} + +#CLEAN UP +- name: Ensure local users do not exist + cisco.nd.nd_local_user: + <<: *nd_info + login_id: "{{ item }}" + state: absent + loop: + - ansible_local_user + - ansible_local_user_2 From 5563927f13447d38c9aa21364c46e3ef725b49c0 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 20 Aug 2025 10:35:38 -0400 Subject: [PATCH 2/5] [ignore] Small adjustments to nd_local_users module and test file. --- plugins/modules/nd_local_user.py | 6 +++--- tests/integration/targets/nd_local_user/tasks/main.yml | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 7572757a..e1da8ddd 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -15,7 +15,7 @@ --- module: nd_local_user version_added: "1.4.0" -short_description: Manage local users on Nexus Dashboard +short_description: Manage local users on Cisco Nexus Dashboard description: - Manage local users on Cisco Nexus Dashboard (ND). - It supports creating, updating, querying, and deleting local users. @@ -152,7 +152,7 @@ - name: Query all local users cisco.nd.nd_local_user: state: query - register: all_keys + register: query_all - name: Delete an local user cisco.nd.nd_local_user: @@ -264,7 +264,7 @@ def main(): elif state == "absent": if nd.existing: if not module.check_mode: - nd.request(path="{0}/{1}".format(path, login_id), method="DELETE") + nd.request(path=updated_path, method="DELETE") nd.existing = {} nd.exit_json() diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index 5bdea3cf..471dfdc6 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -22,7 +22,7 @@ - ansible_local_user - ansible_local_user_2 -#CREATE +# CREATE - name: Create local user (check mode) cisco.nd.nd_local_user: &create_local_user <<: *nd_info @@ -104,7 +104,7 @@ - nm_create_local_user_2.current.loginID == "ansible_local_user_2" - nm_create_local_user_2.current.rbac.domains.all.roles == [] -#UPDATE +# UPDATE - name: Update local user name (check mode) cisco.nd.nd_local_user: &update_local_user <<: *create_local_user @@ -182,7 +182,7 @@ - nm_update_local_user.current.remoteIDClaim == "" - nm_update_local_user.current.xLaunch == false -#QUERY +# QUERY - name: Query all local users cisco.nd.nd_local_user: <<: *nd_info @@ -220,7 +220,7 @@ - query_non_existent is not changed - query_non_existent.current == {} -#DELETE +# DELETE - name: Delete local user by name (check mode) cisco.nd.nd_local_user: &delete_local_user <<: *nd_info @@ -265,7 +265,7 @@ - nm_delete_local_user_again is not changed - nm_delete_local_user_again.current == nm_delete_local_user_again.proposed == nm_delete_local_user_again.previous == {} -#CLEAN UP +# CLEAN UP - name: Ensure local users do not exist cisco.nd.nd_local_user: <<: *nd_info From 8d8666c3361c99ecadc3add72a4d7e89202d4a9a Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Thu, 21 Aug 2025 12:35:09 -0400 Subject: [PATCH 3/5] [ignore] Add get_diff to nd_local_user module for updating tasks. Enhance Documentation and test file. --- plugins/module_utils/nd.py | 78 ++++++------------ plugins/modules/nd_local_user.py | 24 ++++-- .../targets/nd_local_user/tasks/main.yml | 82 ++++++++++++++++++- 3 files changed, 116 insertions(+), 68 deletions(-) diff --git a/plugins/module_utils/nd.py b/plugins/module_utils/nd.py index 52ab88b7..0a33be8b 100644 --- a/plugins/module_utils/nd.py +++ b/plugins/module_utils/nd.py @@ -18,7 +18,6 @@ from ansible.module_utils.basic import json from ansible.module_utils.basic import env_fallback from ansible.module_utils.six import PY3 -from ansible.module_utils.six.moves import filterfalse from ansible.module_utils.six.moves.urllib.parse import urlencode from ansible.module_utils._text import to_native, to_text from ansible.module_utils.connection import Connection @@ -73,53 +72,27 @@ def cmp(a, b): def issubset(subset, superset): - """Recurse through nested dictionary and compare entries""" + """Recurse through a nested dictionary and check if it is a subset of another.""" - # Both objects are the same object - if subset is superset: - return True - - # Both objects are identical - if subset == superset: - return True - - # Both objects have a different type - if isinstance(subset) is not isinstance(superset): + if type(subset) is not type(superset): return False + if not isinstance(subset, dict): + if isinstance(subset, list): + return all(item in superset for item in subset) + return subset == superset + for key, value in subset.items(): - # Ignore empty values if value is None: - return True + continue - # Item from subset is missing from superset if key not in superset: return False - # Item has different types in subset and superset - if isinstance(superset.get(key)) is not isinstance(value): - return False + superset_value = superset.get(key) - # Compare if item values are subset - if isinstance(value, dict): - if not issubset(superset.get(key), value): - return False - elif isinstance(value, list): - try: - # NOTE: Fails for lists of dicts - if not set(value) <= set(superset.get(key)): - return False - except TypeError: - # Fall back to exact comparison for lists of dicts - diff = list(filterfalse(lambda i: i in value, superset.get(key))) + list(filterfalse(lambda j: j in superset.get(key), value)) - if diff: - return False - elif isinstance(value, set): - if not value <= superset.get(key): - return False - else: - if not value == superset.get(key): - return False + if not issubset(value, superset_value): + return False return True @@ -252,7 +225,7 @@ def request( if file is not None: info = conn.send_file_request(method, uri, file, data, None, file_key, file_ext) else: - if data: + if data is not None: info = conn.send_request(method, uri, json.dumps(data)) else: info = conn.send_request(method, uri) @@ -310,6 +283,8 @@ def request( self.fail_json(msg="ND Error: {0}".format(self.error.get("message")), data=data, info=info) self.error = payload if "code" in payload: + if self.status == 404 and ignore_not_found_error: + return {} self.fail_json(msg="ND Error {code}: {message}".format(**payload), data=data, info=info, payload=payload) elif "messages" in payload and len(payload.get("messages")) > 0: self.fail_json(msg="ND Error {code} ({severity}): {message}".format(**payload["messages"][0]), data=data, info=info, payload=payload) @@ -317,10 +292,6 @@ def request( if ignore_not_found_error: return {} self.fail_json(msg="ND Error: {0}".format(payload["errors"][0]), data=data, info=info, payload=payload) - elif "error" in payload and len(payload.get("error")) > 0: - if ignore_not_found_error: - return {} - self.fail_json(msg="ND Error: {0}".format(payload["error"]), data=data, info=info, payload=payload) else: if ignore_not_found_error: return {} @@ -510,30 +481,27 @@ def get_diff(self, unwanted=None): if not self.existing and self.sent: return True - existing = self.existing - sent = self.sent + existing = deepcopy(self.existing) + sent = deepcopy(self.sent) for key in unwanted: if isinstance(key, str): if key in existing: - try: - del existing[key] - except KeyError: - pass - try: - del sent[key] - except KeyError: - pass + del existing[key] + if key in sent: + del sent[key] elif isinstance(key, list): key_path, last = key[:-1], key[-1] try: existing_parent = reduce(dict.get, key_path, existing) - del existing_parent[last] + if existing_parent is not None: + del existing_parent[last] except KeyError: pass try: sent_parent = reduce(dict.get, key_path, sent) - del sent_parent[last] + if sent_parent is not None: + del sent_parent[last] except KeyError: pass return not issubset(sent, existing) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index e1da8ddd..061553be 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -29,6 +29,7 @@ login_id: description: - The login ID of the local user. + - The O(login_id) must be defined when creating, updating or deleting a local user. type: str first_name: description: @@ -43,24 +44,28 @@ - The password of the local user. - Password must have a minimum of 8 characters to a maximum of 64 characters. - Password must have three of the following; one number, one lower case character, one upper case character, one special character. + - The O(user_password) must be defined when creating a new local_user. type: str reuse_limitation: description: - The number of different passwords a user must use before they can reuse a previous one. + - It defaults to C(0) when unset during creation. type: int time_interval_limitation: description: - The minimum time period that must pass before a previous password can be reused. + - It defaults to C(0) when unset during creation. type: int security_domains: description: - The list of Security Domains and Roles for the local user. + - At least, one Security Domain must be defined when creating a new local user. type: list elements: dict suboptions: name: description: - - The name of the Security Domain to which give the local user access. + - The name of the Security Domain to which the local user is given access. type: str required: true aliases: [ security_domain_name, domain_name ] @@ -80,6 +85,7 @@ - To enable/disable the Remote User Authorization of the local user. - Remote User Authorization is used for signing into Nexus Dashboard when using identity providers that cannot provide authorization claims. Once this attribute is enabled, the local user ID cannot be used to directly login to Nexus Dashboard. + - It defaults to C(false) when unset during creation. type: bool state: description: @@ -94,7 +100,7 @@ - cisco.nd.check_mode notes: - This module is only supported on Nexus Dashboard having version 4.1.0 or higher. -- This module is not idempotent when creating or updating a local user object. +- This module is not idempotent when creating or updating a local user object when O(user_password) is used. """ EXAMPLES = r""" @@ -154,7 +160,7 @@ state: query register: query_all -- name: Delete an local user +- name: Delete a local user cisco.nd.nd_local_user: login_id: local_user state: absent @@ -253,13 +259,13 @@ def main(): nd.sanitize(payload) - if not module.check_mode: - if nd.existing: - nd.existing = nd.request(path=updated_path, method="PUT", data=payload) - else: - nd.existing = nd.request(path=path, method="POST", data=payload) - else: + if module.check_mode: nd.existing = nd.proposed + else: + if not nd.existing: + nd.existing = nd.request(path=path, method="POST", data=payload) + elif nd.get_diff(unwanted=[["passwordPolicy", "passwordChangeTime"], ["userID"]]): + nd.existing = nd.request(path=updated_path, method="PUT", data=payload) elif state == "absent": if nd.existing: diff --git a/tests/integration/targets/nd_local_user/tasks/main.yml b/tests/integration/targets/nd_local_user/tasks/main.yml index 471dfdc6..1a334818 100644 --- a/tests/integration/targets/nd_local_user/tasks/main.yml +++ b/tests/integration/targets/nd_local_user/tasks/main.yml @@ -105,8 +105,8 @@ - nm_create_local_user_2.current.rbac.domains.all.roles == [] # UPDATE -- name: Update local user name (check mode) - cisco.nd.nd_local_user: &update_local_user +- name: Update all ansible_local_user's attributes (check mode) + cisco.nd.nd_local_user: &update_first_local_user <<: *create_local_user email: updatedansibleuser@example.com first_name: Updated Ansible first name @@ -123,11 +123,33 @@ check_mode: true register: cm_update_local_user -- name: Update local user name (normal mode) +- name: Update local user (normal mode) cisco.nd.nd_local_user: - <<: *update_local_user + <<: *update_first_local_user register: nm_update_local_user +- name: Update all ansible_local_user_2's attributes except password + cisco.nd.nd_local_user: &update_second_local_user + <<: *nd_info + email: secondansibleuser@example.com + login_id: ansible_local_user_2 + first_name: Second Ansible first name + last_name: Second Ansible last name + reuse_limitation: 20 + time_interval_limitation: 10 + security_domains: + - name: all + roles: fabric_admin + remote_id_claim: ansible_remote_user_2 + remote_user_authorization: true + state: present + register: nm_update_local_user_2 + +- name: Update all ansible_local_user_2's attributes except password again (idempotency) + cisco.nd.nd_local_user: + <<: *update_second_local_user + register: nm_update_local_user_2_again + - name: Verify local user update tasks ansible.builtin.assert: that: @@ -181,6 +203,58 @@ - nm_update_local_user.current.rbac.domains.all.roles.0 == "super-admin" - nm_update_local_user.current.remoteIDClaim == "" - nm_update_local_user.current.xLaunch == false + - nm_update_local_user_2 is changed + - nm_update_local_user_2.previous.loginID == "ansible_local_user_2" + - nm_update_local_user_2.previous.passwordPolicy.reuseLimitation == 0 + - nm_update_local_user_2.previous.passwordPolicy.timeIntervalLimitation == 0 + - nm_update_local_user_2.previous.rbac.domains.all.roles == [] + - nm_update_local_user_2.previous.xLaunch == false + - nm_update_local_user_2.proposed.email == "secondansibleuser@example.com" + - nm_update_local_user_2.proposed.firstName == "Second Ansible first name" + - nm_update_local_user_2.proposed.lastName == "Second Ansible last name" + - nm_update_local_user_2.proposed.loginID == "ansible_local_user_2" + - nm_update_local_user_2.proposed.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user_2.proposed.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user_2.proposed.rbac.domains.all.roles.0 == "fabric-admin" + - nm_update_local_user_2.proposed.remoteIDClaim == "ansible_remote_user_2" + - nm_update_local_user_2.proposed.xLaunch == true + - nm_update_local_user_2.current.email == "secondansibleuser@example.com" + - nm_update_local_user_2.current.firstName == "Second Ansible first name" + - nm_update_local_user_2.current.lastName == "Second Ansible last name" + - nm_update_local_user_2.current.loginID == "ansible_local_user_2" + - nm_update_local_user_2.current.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user_2.current.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user_2.current.rbac.domains.all.roles.0 == "fabric-admin" + - nm_update_local_user_2.current.remoteIDClaim == "ansible_remote_user_2" + - nm_update_local_user_2.current.xLaunch == true + - nm_update_local_user_2_again.previous.email == "secondansibleuser@example.com" + - nm_update_local_user_2_again.previous.firstName == "Second Ansible first name" + - nm_update_local_user_2_again.previous.lastName == "Second Ansible last name" + - nm_update_local_user_2_again.previous.loginID == "ansible_local_user_2" + - nm_update_local_user_2_again.previous.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user_2_again.previous.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user_2_again.previous.rbac.domains.all.roles.0 == "fabric-admin" + - nm_update_local_user_2_again.previous.remoteIDClaim == "ansible_remote_user_2" + - nm_update_local_user_2_again.previous.xLaunch == true + - nm_update_local_user_2_again.proposed.email == "secondansibleuser@example.com" + - nm_update_local_user_2_again.proposed.firstName == "Second Ansible first name" + - nm_update_local_user_2_again.proposed.lastName == "Second Ansible last name" + - nm_update_local_user_2_again.proposed.loginID == "ansible_local_user_2" + - nm_update_local_user_2_again.proposed.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user_2_again.proposed.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user_2_again.proposed.rbac.domains.all.roles.0 == "fabric-admin" + - nm_update_local_user_2_again.proposed.remoteIDClaim == "ansible_remote_user_2" + - nm_update_local_user_2_again.proposed.xLaunch == true + - nm_update_local_user_2_again.current.email == "secondansibleuser@example.com" + - nm_update_local_user_2_again.current.firstName == "Second Ansible first name" + - nm_update_local_user_2_again.current.lastName == "Second Ansible last name" + - nm_update_local_user_2_again.current.loginID == "ansible_local_user_2" + - nm_update_local_user_2_again.current.passwordPolicy.reuseLimitation == 20 + - nm_update_local_user_2_again.current.passwordPolicy.timeIntervalLimitation == 10 + - nm_update_local_user_2_again.current.rbac.domains.all.roles.0 == "fabric-admin" + - nm_update_local_user_2_again.current.remoteIDClaim == "ansible_remote_user_2" + - nm_update_local_user_2_again.current.xLaunch == true + # QUERY - name: Query all local users From 671934655ec6bf33a5238b859f6cc36bea53eab9 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Fri, 22 Aug 2025 12:19:56 -0400 Subject: [PATCH 4/5] [ignore] Replace updated_path with login_id_path in nd_local_user module. --- plugins/modules/nd_local_user.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index 061553be..c30d96fc 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -223,8 +223,8 @@ def main(): path = "/api/v1/infra/aaa/localUsers" if login_id: - updated_path = "{0}/{1}".format(path, login_id) - nd.existing = nd.previous = nd.query_obj(path=updated_path, ignore_not_found_error=True) + login_id_path = "{0}/{1}".format(path, login_id) + nd.existing = nd.previous = nd.query_obj(path=login_id_path, ignore_not_found_error=True) else: nd.existing = nd.query_obj(path=path, ignore_not_found_error=True) @@ -265,12 +265,12 @@ def main(): if not nd.existing: nd.existing = nd.request(path=path, method="POST", data=payload) elif nd.get_diff(unwanted=[["passwordPolicy", "passwordChangeTime"], ["userID"]]): - nd.existing = nd.request(path=updated_path, method="PUT", data=payload) + nd.existing = nd.request(path=login_id_path, method="PUT", data=payload) elif state == "absent": if nd.existing: if not module.check_mode: - nd.request(path=updated_path, method="DELETE") + nd.request(path=login_id_path, method="DELETE") nd.existing = {} nd.exit_json() From 372cb3c7dcaa8933371b956e2c198d370078dd60 Mon Sep 17 00:00:00 2001 From: Gaspard Micol Date: Wed, 27 Aug 2025 13:51:25 -0400 Subject: [PATCH 5/5] [ignore] Add small changes to nd_local_user Documentation. --- plugins/modules/nd_local_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/modules/nd_local_user.py b/plugins/modules/nd_local_user.py index c30d96fc..af35c7c6 100644 --- a/plugins/modules/nd_local_user.py +++ b/plugins/modules/nd_local_user.py @@ -18,7 +18,8 @@ short_description: Manage local users on Cisco Nexus Dashboard description: - Manage local users on Cisco Nexus Dashboard (ND). -- It supports creating, updating, querying, and deleting local users. +- This module supports creating, updating, querying, and deleting local users. +- This module is only supported on ND v4.1.0 and later. author: - Gaspard Micol (@gmicol) options: @@ -99,7 +100,6 @@ - cisco.nd.modules - cisco.nd.check_mode notes: -- This module is only supported on Nexus Dashboard having version 4.1.0 or higher. - This module is not idempotent when creating or updating a local user object when O(user_password) is used. """