From 049bf53f64c1aab7716b50086998e450d5a15bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiziano=20M=C3=BCller?= Date: Thu, 4 Jun 2026 12:00:07 +0200 Subject: [PATCH] sudoers: add defaults attribute to allow specifying scoped defaults Assisted-by: multi-model agent Co-authored-by: Alexei Znamensky <103110+russoz@users.noreply.github.com> --- changelogs/fragments/sudoers-defaults.yml | 2 + plugins/modules/sudoers.py | 32 +++++++++++++- .../targets/sudoers/tasks/main.yml | 43 +++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/sudoers-defaults.yml diff --git a/changelogs/fragments/sudoers-defaults.yml b/changelogs/fragments/sudoers-defaults.yml new file mode 100644 index 0000000000..9d99ba71e8 --- /dev/null +++ b/changelogs/fragments/sudoers-defaults.yml @@ -0,0 +1,2 @@ +minor_changes: + - sudoers - add ``defaults`` parameter to allow specifying ``Defaults`` directives scoped to the user or group in the generated sudoers file (https://github.com/ansible-collections/community.general/pull/12186). diff --git a/plugins/modules/sudoers.py b/plugins/modules/sudoers.py index 60394c6bfd..cefe852329 100644 --- a/plugins/modules/sudoers.py +++ b/plugins/modules/sudoers.py @@ -88,6 +88,16 @@ options: - The name of the user for the sudoers rule. - This option cannot be used in conjunction with O(group). type: str + defaults: + description: + - A list of C(Defaults) directives to include in the sudoers rule file. + - Each entry is written as a C(Defaults) line scoped to the user or group specified in the rule. + - For example, V(!targetpw) becomes C(Defaults:%group !targetpw) for a group rule + or C(Defaults:user !targetpw) for a user rule. + - The directives are placed before the privilege rule in the generated file. + type: list + elements: str + version_added: 13.1.0 validation: description: - If V(absent), the sudoers rule is added without validation. @@ -155,6 +165,15 @@ EXAMPLES = r""" user: alice commands: /usr/bin/less noexec: true + +- name: Allow members of the operators group to sudo with their user password, overriding targetpw default + community.general.sudoers: + name: operators + group: operators + commands: ALL + nopassword: false + defaults: + - "!targetpw" """ import os @@ -180,6 +199,7 @@ class Sudoers: self.sudoers_path = module.params["sudoers_path"] self.file = os.path.join(self.sudoers_path, self.name) self.commands = module.params["commands"] + self.defaults = module.params["defaults"] self.validation = module.params["validation"] def write(self): @@ -215,12 +235,18 @@ class Sudoers: elif self.group: owner = f"%{self.group}" + if self.defaults: + defaults_lines = [f"Defaults:{owner} {d}" for d in self.defaults] + defaults_str = "\n".join(defaults_lines) + "\n" + else: + defaults_str = "" + commands_str = ", ".join(self.commands) noexec_str = "NOEXEC:" if self.noexec else "" nopasswd_str = "NOPASSWD:" if self.nopassword else "" setenv_str = "SETENV:" if self.setenv else "" runas_str = f"({self.runas})" if self.runas is not None else "" - return f"{owner} {self.host}={runas_str}{noexec_str}{nopasswd_str}{setenv_str} {commands_str}\n" + return f"{defaults_str}{owner} {self.host}={runas_str}{noexec_str}{nopasswd_str}{setenv_str} {commands_str}\n" def validate(self): if self.validation == "absent": @@ -261,6 +287,10 @@ def main(): "type": "list", "elements": "str", }, + "defaults": { + "type": "list", + "elements": "str", + }, "group": {}, "name": { "required": True, diff --git a/tests/integration/targets/sudoers/tasks/main.yml b/tests/integration/targets/sudoers/tasks/main.yml index fa03b71dac..3ec9e1f6c9 100644 --- a/tests/integration/targets/sudoers/tasks/main.yml +++ b/tests/integration/targets/sudoers/tasks/main.yml @@ -173,6 +173,39 @@ src: "{{ sudoers_path }}/my-sudo-rule-9" register: rule_9_contents +- name: Create rule with defaults directives for a group + community.general.sudoers: + name: my-sudo-rule-10 + state: present + group: operators + commands: ALL + nopassword: false + defaults: + - "!targetpw" + register: rule_10 + +- name: Grab contents of my-sudo-rule-10 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-10" + register: rule_10_contents + +- name: Create rule with defaults directives for a user + community.general.sudoers: + name: my-sudo-rule-11 + state: present + user: alice + commands: ALL + nopassword: false + defaults: + - "!targetpw" + - "env_reset" + register: rule_11 + +- name: Grab contents of my-sudo-rule-11 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-11" + register: rule_11_contents + - name: Revoke rule 1 community.general.sudoers: name: my-sudo-rule-1 @@ -261,6 +294,14 @@ - revoke_non_existing_rule is not changed - name: Check contents + vars: + expected_result_rule10: | + Defaults:%operators !targetpw + %operators ALL= ALL + expected_result_rule11: | + Defaults:alice !targetpw + Defaults:alice env_reset + alice ALL= ALL ansible.builtin.assert: that: - "rule_1_contents['content'] | b64decode == 'alice ALL=NOPASSWD: /usr/local/bin/command\n'" @@ -272,6 +313,8 @@ - "rule_7_contents['content'] | b64decode == 'alice host-1=NOPASSWD: /usr/local/bin/command\n'" - "rule_8_contents['content'] | b64decode == 'alice ALL=NOPASSWD:SETENV: /usr/local/bin/command\n'" - "rule_9_contents['content'] | b64decode == 'alice ALL=NOEXEC:NOPASSWD: /usr/local/bin/command\n'" + - "rule_10_contents['content'] | b64decode == expected_result_rule10" + - "rule_11_contents['content'] | b64decode == expected_result_rule11" - name: Check revocation stat ansible.builtin.assert: