#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2019, Jon Ellis (@JonEllis) # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later 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 (@JonEllis) " options: commands: description: - The commands allowed by the sudoers rule. - Multiple can be added by passing a list of commands. - Use C(ALL) for all 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 setenv: description: - Whether to allow keeping the environment when command is run with sudo. default: false type: bool version_added: 6.3.0 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. type: str version_added: 4.7.0 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 validation: description: - If C(absent), the sudoers rule will be added without validation. - If C(detect) and visudo is available, then the sudoers rule will be validated by visudo. - If C(required), visudo must be available to validate the sudoers rule. type: str default: detect choices: [ absent, detect, required ] version_added: 5.2.0 ''' 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 bob user to run any commands as alice with sudo -u alice community.general.sudoers: name: bob-do-as-alice state: present user: bob runas: alice commands: ALL - name: >- Allow the monitoring group to run sudo /usr/local/bin/gather-app-metrics 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: >- 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 - name: Allow alice to sudo /usr/local/bin/upload and keep env variables community.general.sudoers: name: allow-alice-upload user: alice commands: /usr/local/bin/upload setenv: true ''' import os from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.common.text.converters import to_native class Sudoers(object): FILE_MODE = 0o440 def __init__(self, module): self.module = 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.setenv = module.params['setenv'] 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) self.commands = module.params['commands'] self.validation = module.params['validation'] def write(self): if self.check_mode: return with open(self.file, 'w') as f: f.write(self.content()) os.chmod(self.file, self.FILE_MODE) 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: content_matches = f.read() == self.content() current_mode = os.stat(self.file).st_mode & 0o777 mode_matches = current_mode == self.FILE_MODE return content_matches and mode_matches 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 '' setenv_str = 'SETENV:' if self.setenv else '' runas_str = '({runas})'.format(runas=self.runas) if self.runas is not None else '' return "{owner} {host}={runas}{nopasswd}{setenv} {commands}\n".format( owner=owner, host=self.host, runas=runas_str, nopasswd=nopasswd_str, setenv=setenv_str, commands=commands_str ) def validate(self): if self.validation == 'absent': return visudo_path = self.module.get_bin_path('visudo', required=self.validation == 'required') if visudo_path is None: return check_command = [visudo_path, '-c', '-f', '-'] rc, stdout, stderr = self.module.run_command(check_command, data=self.content()) if rc != 0: raise Exception('Failed to validate sudoers rule:\n{stdout}'.format(stdout=stdout)) def run(self): if self.state == 'absent': if self.exists(): self.delete() return True else: return False self.validate() 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, }, 'setenv': { 'type': 'bool', 'default': False, }, 'host': { 'type': 'str', 'default': 'ALL', }, 'runas': { 'type': 'str', 'default': None, }, 'sudoers_path': { 'type': 'str', 'default': '/etc/sudoers.d', }, 'state': { 'default': 'present', 'choices': ['present', 'absent'], }, 'user': {}, 'validation': { 'default': 'detect', 'choices': ['absent', 'detect', 'required'] }, } 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()