diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index bf99fdb75b..09bd623981 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1092,6 +1092,8 @@ files: keywords: beadm dladm illumos ipadm nexenta omnios openindiana pfexec smartos solaris sunos zfs zpool $modules/system/ssh_config.py: maintainers: gaqzi Akasurde + $modules/system/sudoers.py: + maintainers: JonEllis $modules/system/svc.py: maintainers: bcoca $modules/system/syspatch.py: diff --git a/plugins/modules/sudoers.py b/plugins/modules/sudoers.py new file mode 120000 index 0000000000..1bb579bf6c --- /dev/null +++ b/plugins/modules/sudoers.py @@ -0,0 +1 @@ +system/sudoers.py \ No newline at end of file diff --git a/plugins/modules/system/sudoers.py b/plugins/modules/system/sudoers.py new file mode 100644 index 0000000000..5a97d6f4b4 --- /dev/null +++ b/plugins/modules/system/sudoers.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + + +# Copyright: (c) 2019, Jon Ellis (@JonEllis) +# 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 = ''' +--- +module: sudoers +short_description: Manage sudoers files +version_added: "4.3.0" +description: + - This module allows for the manipulation of sudoers files. +author: + - "Jon Ellis (@JonEllis0) " +options: + commands: + description: + - The commands allowed by the sudoers rule. + - Multiple can be added by passing a list of commands. + type: list + elements: str + group: + description: + - The name of the group for the sudoers rule. + - This option cannot be used in conjunction with I(user). + type: str + name: + required: true + description: + - The name of the sudoers rule. + - This will be used for the filename for the sudoers file managed by this rule. + type: str + nopassword: + description: + - Whether a password will be required to run the sudo'd command. + default: true + type: bool + sudoers_path: + description: + - The path which sudoers config files will be managed in. + default: /etc/sudoers.d + type: str + state: + default: "present" + choices: + - present + - absent + description: + - Whether the rule should exist or not. + type: str + user: + description: + - The name of the user for the sudoers rule. + - This option cannot be used in conjunction with I(group). + type: str +''' + +EXAMPLES = ''' +- name: Allow the backup user to sudo /usr/local/bin/backup + community.general.sudoers: + name: allow-backup + state: present + user: backup + commands: /usr/local/bin/backup + +- name: >- + Allow the monitoring group to run sudo /usr/local/bin/gather-app-metrics + without requiring a password + community.general.sudoers: + name: monitor-app + group: monitoring + commands: /usr/local/bin/gather-app-metrics + +- name: >- + Allow the alice user to run sudo /bin/systemctl restart my-service or + sudo /bin/systemctl reload my-service, but a password is required + community.general.sudoers: + name: alice-service + user: alice + commands: + - /bin/systemctl restart my-service + - /bin/systemctl reload my-service + nopassword: false + +- name: Revoke the previous sudo grants given to the alice user + community.general.sudoers: + name: alice-service + state: absent +''' + +import os +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + + +class Sudoers(object): + + def __init__(self, module): + self.check_mode = module.check_mode + self.name = module.params['name'] + self.user = module.params['user'] + self.group = module.params['group'] + self.state = module.params['state'] + self.nopassword = module.params['nopassword'] + self.sudoers_path = module.params['sudoers_path'] + self.file = os.path.join(self.sudoers_path, self.name) + self.commands = module.params['commands'] + + def write(self): + if self.check_mode: + return + + with open(self.file, 'w') as f: + f.write(self.content()) + + def delete(self): + if self.check_mode: + return + + os.remove(self.file) + + def exists(self): + return os.path.exists(self.file) + + def matches(self): + with open(self.file, 'r') as f: + return f.read() == self.content() + + def content(self): + if self.user: + owner = self.user + elif self.group: + owner = '%{group}'.format(group=self.group) + + commands_str = ', '.join(self.commands) + nopasswd_str = 'NOPASSWD:' if self.nopassword else '' + return "{owner} ALL={nopasswd} {commands}\n".format(owner=owner, nopasswd=nopasswd_str, commands=commands_str) + + def run(self): + if self.state == 'absent' and self.exists(): + self.delete() + return True + + if self.exists() and self.matches(): + return False + + self.write() + return True + + +def main(): + argument_spec = { + 'commands': { + 'type': 'list', + 'elements': 'str', + }, + 'group': {}, + 'name': { + 'required': True, + }, + 'nopassword': { + 'type': 'bool', + 'default': True, + }, + 'sudoers_path': { + 'type': 'str', + 'default': '/etc/sudoers.d', + }, + 'state': { + 'default': 'present', + 'choices': ['present', 'absent'], + }, + 'user': {}, + } + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[['user', 'group']], + supports_check_mode=True, + required_if=[('state', 'present', ['commands'])], + ) + + sudoers = Sudoers(module) + + try: + changed = sudoers.run() + module.exit_json(changed=changed) + except Exception as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/sudoers/aliases b/tests/integration/targets/sudoers/aliases new file mode 100644 index 0000000000..765b70da79 --- /dev/null +++ b/tests/integration/targets/sudoers/aliases @@ -0,0 +1 @@ +shippable/posix/group2 diff --git a/tests/integration/targets/sudoers/tasks/main.yml b/tests/integration/targets/sudoers/tasks/main.yml new file mode 100644 index 0000000000..3a4f778d75 --- /dev/null +++ b/tests/integration/targets/sudoers/tasks/main.yml @@ -0,0 +1,140 @@ +--- +# Initialise environment + +- name: Register sudoers.d directory + set_fact: + sudoers_path: /etc/sudoers.d + alt_sudoers_path: /etc/sudoers_alt + +- name: Ensure sudoers directory exists + ansible.builtin.file: + path: "{{ sudoers_path }}" + state: directory + recurse: true + +- name: Ensure alternative sudoers directory exists + ansible.builtin.file: + path: "{{ alt_sudoers_path }}" + state: directory + recurse: true + + +# Run module and collect data + +- name: Create first rule + community.general.sudoers: + name: my-sudo-rule-1 + state: present + user: alice + commands: /usr/local/bin/command + register: rule_1 + +- name: Grab contents of my-sudo-rule-1 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-1" + register: rule_1_contents + +- name: Create first rule again + community.general.sudoers: + name: my-sudo-rule-1 + state: present + user: alice + commands: /usr/local/bin/command + register: rule_1_again + + +- name: Create second rule with two commands + community.general.sudoers: + name: my-sudo-rule-2 + state: present + user: alice + commands: + - /usr/local/bin/command1 + - /usr/local/bin/command2 + register: rule_2 + +- name: Grab contents of my-sudo-rule-2 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-2" + register: rule_2_contents + + +- name: Create rule requiring a password + community.general.sudoers: + name: my-sudo-rule-3 + state: present + user: alice + commands: /usr/local/bin/command + nopassword: false + register: rule_3 + +- name: Grab contents of my-sudo-rule-3 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-3" + register: rule_3_contents + + +- name: Create rule using a group + community.general.sudoers: + name: my-sudo-rule-4 + state: present + group: students + commands: /usr/local/bin/command + register: rule_4 + +- name: Grab contents of my-sudo-rule-4 + ansible.builtin.slurp: + src: "{{ sudoers_path }}/my-sudo-rule-4" + register: rule_4_contents + + +- name: Create rule in a alternative directory + community.general.sudoers: + name: my-sudo-rule-5 + state: present + user: alice + commands: /usr/local/bin/command + sudoers_path: "{{ alt_sudoers_path }}" + register: rule_5 + +- name: Grab contents of my-sudo-rule-5 (in alternative directory) + ansible.builtin.slurp: + src: "{{ alt_sudoers_path }}/my-sudo-rule-5" + register: rule_5_contents + + +- name: Revoke rule 1 + community.general.sudoers: + name: my-sudo-rule-1 + state: absent + register: revoke_rule_1 + +- name: Stat rule 1 + ansible.builtin.stat: + path: "{{ sudoers_path }}/my-sudo-rule-1" + register: revoke_rule_1_stat + + +# Run assertions + +- name: Check changed status + ansible.builtin.assert: + that: + - rule_1 is changed + - rule_1_again is not changed + - rule_5 is changed + - revoke_rule_1 is changed + +- name: Check contents + ansible.builtin.assert: + that: + - "rule_1_contents['content'] | b64decode == 'alice ALL=NOPASSWD: /usr/local/bin/command\n'" + - "rule_2_contents['content'] | b64decode == 'alice ALL=NOPASSWD: /usr/local/bin/command1, /usr/local/bin/command2\n'" + - "rule_3_contents['content'] | b64decode == 'alice ALL= /usr/local/bin/command\n'" + - "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'" + +- name: Check stats + ansible.builtin.assert: + that: + - not revoke_rule_1_stat.stat.exists