From d859ddc7fea047fab0f69817a18083bb2b5a43ff Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Mon, 26 Sep 2022 16:09:15 -0300
Subject: [PATCH] sudorule: Add support for 'hostmask' parameter

The hostmask parameter allows matching a sudorule against a network
address, and was missing from ipasudorule module.

Documentation and tests were updated to reflect changes.

Two new example playbooks are available:

    playbooks/sudorule/ensure-sudorule-hostmask-member-is-absent.yml
    playbooks/sudorule/ensure-sudorule-hostmask-member-is-present.yml
---
 README-sudorule.md                            |   1 +
 ...ure-sudorule-hostmask-member-is-absent.yml |  14 +++
 ...re-sudorule-hostmask-member-is-present.yml |  13 ++
 plugins/modules/ipasudorule.py                |  63 +++++++---
 tests/sudorule/test_sudorule.yml              | 112 ++++++++++++++++++
 5 files changed, 188 insertions(+), 15 deletions(-)
 create mode 100644 playbooks/sudorule/ensure-sudorule-hostmask-member-is-absent.yml
 create mode 100644 playbooks/sudorule/ensure-sudorule-hostmask-member-is-present.yml

diff --git a/README-sudorule.md b/README-sudorule.md
index 089adab2..bd23c178 100644
--- a/README-sudorule.md
+++ b/README-sudorule.md
@@ -129,6 +129,7 @@ Variable | Description | Required
 `nomembers` | Suppress processing of membership attributes. (bool) | no
 `host` | List of host name strings assigned to this sudorule. | no
 `hostgroup` | List of host group name strings assigned to this sudorule. | no
+`hostmask` | List of host masks of allowed hosts | no
 `user` | List of user name strings assigned to this sudorule. | no
 `group` | List of user group name strings assigned to this sudorule. | no
 `allow_sudocmd` | List of sudocmd name strings assigned to the allow group of this sudorule. | no
diff --git a/playbooks/sudorule/ensure-sudorule-hostmask-member-is-absent.yml b/playbooks/sudorule/ensure-sudorule-hostmask-member-is-absent.yml
new file mode 100644
index 00000000..32f8bc36
--- /dev/null
+++ b/playbooks/sudorule/ensure-sudorule-hostmask-member-is-absent.yml
@@ -0,0 +1,14 @@
+---
+- name: Playbook to manage sudorule
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure hostmask network is absent in sudorule
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule1
+      hostmask: 192.168.122.37/24
+      action: member
+      state: absent
diff --git a/playbooks/sudorule/ensure-sudorule-hostmask-member-is-present.yml b/playbooks/sudorule/ensure-sudorule-hostmask-member-is-present.yml
new file mode 100644
index 00000000..51cb968d
--- /dev/null
+++ b/playbooks/sudorule/ensure-sudorule-hostmask-member-is-present.yml
@@ -0,0 +1,13 @@
+---
+- name: Playbook to manage sudorule
+  hosts: ipaserver
+  become: no
+  gather_facts: no
+
+  tasks:
+  - name: Ensure hostmask network is present in sudorule
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule1
+      hostmask: 192.168.122.37/24
+      action: member
diff --git a/plugins/modules/ipasudorule.py b/plugins/modules/ipasudorule.py
index bd00ae1d..2be49c22 100644
--- a/plugins/modules/ipasudorule.py
+++ b/plugins/modules/ipasudorule.py
@@ -143,6 +143,11 @@ options:
     required: false
     type: list
     elements: str
+  hostmask:
+    description: Host masks of allowed hosts.
+    required: false
+    type: list
+    elements: str
   action:
     description: Work on sudorule or member level
     type: str
@@ -202,6 +207,15 @@ EXAMPLES = """
     hostcategory: all
     state: enabled
 
+# Ensure sudo rule applies for hosts with hostmasks
+- ipasudorule:
+    ipaadmin_password: SomeADMINpassword
+    name: testrule1
+    hostmask:
+    - 192.168.122.1/24
+    - 192.168.120.1/24
+    action: member
+
 # Ensure Sudo Rule tesrule1 is absent
 - ipasudorule:
     ipaadmin_password: SomeADMINpassword
@@ -214,7 +228,7 @@ RETURN = """
 
 from ansible.module_utils.ansible_freeipa_module import \
     IPAAnsibleModule, compare_args_ipa, gen_add_del_lists, gen_add_list, \
-    gen_intersection_list, api_get_domain, ensure_fqdn
+    gen_intersection_list, api_get_domain, ensure_fqdn, netaddr, to_text
 
 
 def find_sudorule(module, name):
@@ -275,6 +289,8 @@ def main():
                       default=None),
             hostgroup=dict(required=False, type='list', elements="str",
                            default=None),
+            hostmask=dict(required=False, type='list', elements="str",
+                          default=None),
             user=dict(required=False, type='list', elements="str",
                       default=None),
             group=dict(required=False, type='list', elements="str",
@@ -334,6 +350,7 @@ def main():
     nomembers = ansible_module.params_get("nomembers")  # noqa
     host = ansible_module.params_get("host")
     hostgroup = ansible_module.params_get_lowercase("hostgroup")
+    hostmask = ansible_module.params_get("hostmask")
     user = ansible_module.params_get_lowercase("user")
     group = ansible_module.params_get_lowercase("group")
     allow_sudocmd = ansible_module.params_get('allow_sudocmd')
@@ -351,6 +368,10 @@ def main():
     # state
     state = ansible_module.params_get("state")
 
+    # ensure hostmasks are network cidr
+    if hostmask is not None:
+        hostmask = [to_text(netaddr.IPNetwork(x).cidr) for x in hostmask]
+
     # Check parameters
     invalid = []
 
@@ -382,7 +403,7 @@ def main():
                    "cmdcategory", "runasusercategory",
                    "runasgroupcategory", "nomembers", "order"]
         if action == "sudorule":
-            invalid.extend(["host", "hostgroup", "user", "group",
+            invalid.extend(["host", "hostgroup", "hostmask", "user", "group",
                             "runasuser", "runasgroup", "allow_sudocmd",
                             "allow_sudocmdgroup", "deny_sudocmd",
                             "deny_sudocmdgroup", "sudooption"])
@@ -396,7 +417,7 @@ def main():
                 "disabled")
         invalid = ["description", "usercategory", "hostcategory",
                    "cmdcategory", "runasusercategory", "runasgroupcategory",
-                   "nomembers", "nomembers", "host", "hostgroup",
+                   "nomembers", "nomembers", "host", "hostgroup", "hostmask",
                    "user", "group", "allow_sudocmd", "allow_sudocmdgroup",
                    "deny_sudocmd", "deny_sudocmdgroup", "runasuser",
                    "runasgroup", "order", "sudooption"]
@@ -425,6 +446,7 @@ def main():
         user_add, user_del = [], []
         group_add, group_del = [], []
         hostgroup_add, hostgroup_del = [], []
+        hostmask_add, hostmask_del = [], []
         allow_cmd_add, allow_cmd_del = [], []
         allow_cmdgroup_add, allow_cmdgroup_del = [], []
         deny_cmd_add, deny_cmd_del = [], []
@@ -490,6 +512,9 @@ def main():
                     hostgroup_add, hostgroup_del = gen_add_del_lists(
                         hostgroup, res_find.get('memberhost_hostgroup', []))
 
+                    hostmask_add, hostmask_del = gen_add_del_lists(
+                        hostmask, res_find.get('hostmask', []))
+
                     user_add, user_del = gen_add_del_lists(
                         user, res_find.get('memberuser_user', []))
 
@@ -556,6 +581,9 @@ def main():
                     if hostgroup is not None:
                         hostgroup_add = gen_add_list(
                             hostgroup, res_find.get("memberhost_hostgroup"))
+                    if hostmask is not None:
+                        hostmask_add = gen_add_list(
+                            hostmask, res_find.get("hostmask"))
                     if user is not None:
                         user_add = gen_add_list(
                             user, res_find.get("memberuser_user"))
@@ -628,6 +656,10 @@ def main():
                         hostgroup_del = gen_intersection_list(
                             hostgroup, res_find.get("memberhost_hostgroup"))
 
+                    if hostmask is not None:
+                        hostmask_del = gen_intersection_list(
+                            hostmask, res_find.get("hostmask"))
+
                     if user is not None:
                         user_del = gen_intersection_list(
                             user, res_find.get("memberuser_user"))
@@ -719,18 +751,19 @@ def main():
 
             # Manage members.
             # Manage hosts and hostgroups
-            if host_add or hostgroup_add:
-                commands.append([name, "sudorule_add_host",
-                                 {
-                                     "host": host_add,
-                                     "hostgroup": hostgroup_add,
-                                 }])
-            if host_del or hostgroup_del:
-                commands.append([name, "sudorule_remove_host",
-                                 {
-                                     "host": host_del,
-                                     "hostgroup": hostgroup_del,
-                                 }])
+            if any([host_add, hostgroup_add, hostmask_add]):
+                params = {"host": host_add, "hostgroup": hostgroup_add}
+                # An empty Hostmask cannot be used, or IPA API will fail.
+                if hostmask_add:
+                    params["hostmask"] = hostmask_add
+                commands.append([name, "sudorule_add_host", params])
+
+            if any([host_del, hostgroup_del, hostmask_del]):
+                params = {"host": host_del, "hostgroup": hostgroup_del}
+                # An empty Hostmask cannot be used, or IPA API will fail.
+                if hostmask_del:
+                    params["hostmask"] = hostmask_del
+                commands.append([name, "sudorule_remove_host", params])
 
             # Manage users and groups
             if user_add or group_add:
diff --git a/tests/sudorule/test_sudorule.yml b/tests/sudorule/test_sudorule.yml
index 0ba8d8fe..622438cd 100644
--- a/tests/sudorule/test_sudorule.yml
+++ b/tests/sudorule/test_sudorule.yml
@@ -83,6 +83,7 @@
       ipaapi_context: "{{ ipa_context | default(omit) }}"
       name:
       - test_upstream_issue_664
+      - testrule_hostmask
       - testrule1
       - allusers
       - allhosts
@@ -1005,6 +1006,116 @@
     register: result
     failed_when: not result.changed or result.failed
 
+  - name: Ensure sudorule is present with hostmask
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule_hostmask
+      hostmask:
+      - 192.168.122.1/24
+      - 192.168.120.1/24
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule is present with hostmask, again
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule_hostmask
+      hostmask:
+      - 192.168.122.1/24
+      - 192.168.120.1/24
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is absent
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule_hostmask
+      hostmask: 192.168.122.0/24
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is absent, again
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule_hostmask
+      hostmask: 192.168.122.0/24
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure sudorule is present with another hostmask
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      name: testrule_hostmask
+      hostmask: 192.168.122.0/24
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule is present with another hostmask, again
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.122.0/24
+    register: result
+    failed_when: result.changed
+
+  - name: Check sudorule with hostmask is absent
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.120.0/24
+      action: member
+    register: result
+    check_mode: yes
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is present
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.120.0/24
+      action: member
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is present, again
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.120.0/24
+      action: member
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is absent
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.120.0/24
+      action: member
+      state: absent
+    register: result
+    failed_when: not result.changed or result.failed
+
+  - name: Ensure sudorule hostmask member is absent, again
+    ipasudorule:
+      ipaadmin_password: SomeADMINpassword
+      ipaapi_context: "{{ ipa_context | default(omit) }}"
+      name: testrule_hostmask
+      hostmask: 192.168.120.0/24
+      action: member
+      state: absent
+    register: result
+    failed_when: result.changed or result.failed
+
   # cleanup
   - name: Ensure sudocmdgroup is absent
     ipasudocmdgroup:
@@ -1013,6 +1124,7 @@
       name:
       - test_sudorule
       - test_sudorule2
+      - testrule_hostmask
       state: absent
 
   - name: Ensure sudocmds are absent
-- 
GitLab