Repository: ansible-collections/community.general

Issue:
sudoers module does not support host restrictions
### Summary

Currently the sudoers module is hardcoded to set the host to the magic value `ALL`:

```python
return "{owner} ALL={runas}{nopasswd} {commands}\n".format(owner=owner, runas=runas_str, nopasswd=nopasswd_str, commands=commands_str)
```

This is a problem for me in two different scenarios I have:

1. Some network booted (diskless) systems with a shared `/etc/sudoers.d` (network) filesystem using the host restrictions to constrain which commands are permitted on which host
2. A security policy that requires specifying the host to mitigate the risk of inappropriate escalation being possible if the file is inadvertently copied to the wrong host (e.g. via poorly aimed `scp`, restoring the wrong host's `/etc/sudoers.d` from backup or copying it inappropriately after mounting the disk on a different host in a DR situation - all 3 of which have happened at some point and I'm sure there's more ways to mess it up too)

If accepted, this is a trivial feature to add - it is essentially a carbon-copy of `runas` just before the `=`.

In comparing the code with the `sudoers` manual, I noticed that like the host list - `runas` supports a list of users but that has not been implemented either - just noting it, I have no use case for needing that feature (or supporting a list for the host).

### Issue Type

Feature Idea

### Component Name

sudoers

### Additional Information

<!--- Paste example playbooks or commands between quotes below -->
```yaml (paste below)

```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct


Discussion:
Files identified in the description:
* [`plugins/modules/sudoers.py`](https://github.com/ansible-collections/community.general/blob/main/plugins/modules/sudoers.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @JonEllis @JonEllis0
[click here for bot help](https://github.com/ansible/ansibullbot/blob/devel/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/plugins/modules/sudoers.py b/plugins/modules/sudoers.py
--- a/plugins/modules/sudoers.py
+++ b/plugins/modules/sudoers.py
@@ -43,6 +43,12 @@
       - Whether a password will be required to run the sudo'd command.
     default: true
     type: bool
+  host:
+    description:
+      - Specify the host the rule is for.
+    default: ALL
+    type: str
+    version_added: 6.2.0
   runas:
     description:
       - Specify the target user the command(s) will run as.
@@ -95,10 +101,11 @@
 
 - name: >-
     Allow the monitoring group to run sudo /usr/local/bin/gather-app-metrics
-    without requiring a password
+    without requiring a password on the host called webserver
   community.general.sudoers:
     name: monitor-app
     group: monitoring
+    host: webserver
     commands: /usr/local/bin/gather-app-metrics
 
 - name: >-
@@ -136,6 +143,7 @@ def __init__(self, module):
         self.group = module.params['group']
         self.state = module.params['state']
         self.nopassword = module.params['nopassword']
+        self.host = module.params['host']
         self.runas = module.params['runas']
         self.sudoers_path = module.params['sudoers_path']
         self.file = os.path.join(self.sudoers_path, self.name)
@@ -178,7 +186,13 @@ def content(self):
         commands_str = ', '.join(self.commands)
         nopasswd_str = 'NOPASSWD:' if self.nopassword else ''
         runas_str = '({runas})'.format(runas=self.runas) if self.runas is not None else ''
-        return "{owner} ALL={runas}{nopasswd} {commands}\n".format(owner=owner, runas=runas_str, nopasswd=nopasswd_str, commands=commands_str)
+        return "{owner} {host}={runas}{nopasswd} {commands}\n".format(
+            owner=owner,
+            host=self.host,
+            runas=runas_str,
+            nopasswd=nopasswd_str,
+            commands=commands_str
+        )
 
     def validate(self):
         if self.validation == 'absent':
@@ -225,6 +239,10 @@ def main():
             'type': 'bool',
             'default': True,
         },
+        'host': {
+            'type': 'str',
+            'default': 'ALL',
+        },
         'runas': {
             'type': 'str',
             'default': None,


Test patch:
diff --git a/tests/integration/targets/sudoers/tasks/main.yml b/tests/integration/targets/sudoers/tasks/main.yml
--- a/tests/integration/targets/sudoers/tasks/main.yml
+++ b/tests/integration/targets/sudoers/tasks/main.yml
@@ -131,6 +131,19 @@
     src: "{{ sudoers_path }}/my-sudo-rule-6"
   register: rule_6_contents
 
+- name: Create rule to allow user to sudo just on host-1
+  community.general.sudoers:
+    name: my-sudo-rule-7
+    state: present
+    user: alice
+    host: host-1
+    commands: /usr/local/bin/command
+  register: rule_7
+
+- name: Grab contents of my-sudo-rule-7
+  ansible.builtin.slurp:
+    src: "{{ sudoers_path }}/my-sudo-rule-7"
+  register: rule_7_contents
 
 - name: Revoke rule 1
   community.general.sudoers:
@@ -229,6 +242,7 @@
       - "rule_4_contents['content'] | b64decode == '%students ALL=NOPASSWD: /usr/local/bin/command\n'"
       - "rule_5_contents['content'] | b64decode == 'alice ALL=NOPASSWD: /usr/local/bin/command\n'"
       - "rule_6_contents['content'] | b64decode == 'alice ALL=(bob)NOPASSWD: /usr/local/bin/command\n'"
+      - "rule_7_contents['content'] | b64decode == 'alice host-1=NOPASSWD: /usr/local/bin/command\n'"
 
 - name: Check revocation stat
   ansible.builtin.assert: