From 64b57908bc7b9842703d7254437cbac67970b342 Mon Sep 17 00:00:00 2001 From: Nate Coraor Date: Tue, 22 Jan 2019 21:51:06 -0500 Subject: [PATCH] A module for managing ZFS admin privileges (#19240) * Create a module for managing ZFS admin privileges * Actually support check mode in zfs_permissions * Fix Python 2.4 invalid syntax * Update added_version to 2.1 * Remove superflous sys.exit() and add an empty RETURN doc (empty because the module does not return anything other than status-related stuff) * Update version_added since zfs_permissions was not accepted in 2.1. * - Rename zfs_permissions to zfs_delegate_admin to more accurately reflect its purpose. - Update with current Ansible module best practices. - Stop checking whether changes need to be made (this was flawed since it was not able to detect whether, for example, changes would be made with `local=True` when the permissions already existed for `Local+Descendent`. Instead, just make the changes (`zfs allow` is idempotent) and compare the state before and after. - Generally write things in a better way. * Fix test errors * Fix more tests. * Catch exceptions when parsing `zfs allow` output. At least on one version of ZFS (on a 2016 version of illumos) will have a single space in the user or group column if the name can't be resolved by the system's name services). --- .../modules/storage/zfs/zfs_delegate_admin.py | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 lib/ansible/modules/storage/zfs/zfs_delegate_admin.py diff --git a/lib/ansible/modules/storage/zfs/zfs_delegate_admin.py b/lib/ansible/modules/storage/zfs/zfs_delegate_admin.py new file mode 100644 index 00000000000..749dba5b5a1 --- /dev/null +++ b/lib/ansible/modules/storage/zfs/zfs_delegate_admin.py @@ -0,0 +1,263 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2015, Nate Coraor +# +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . +# +from __future__ import absolute_import +__metaclass__ = type + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + + +DOCUMENTATION = ''' +--- +module: zfs_delegate_admin +short_description: Manage ZFS delegated administration (user admin privileges) +description: + - Manages ZFS file system delegated administration permissions, which allow unprivileged users to perform ZFS + operations normally restricted to the superuser. + - See the "zfs allow" section of C(zfs(1M)) for detailed explanations of options. This module attempts to adhere to + the behavior of the command line tool as much as possible. +requirements: + - "A ZFS/OpenZFS implementation that supports delegation with `zfs allow`, including: Solaris >= 10, illumos (all + versions), FreeBSD >= 8.0R, ZFS on Linux >= 0.7.0." +version_added: "2.5" +options: + name: + description: + - File system or volume name e.g. C(rpool/myfs) + required: true + state: + description: + - Whether to allow (C(present)), or unallow (C(absent)) a permission. When set to C(present), at least one + "entity" param of I(users), I(groups), or I(everyone) are required. When set to C(absent), removes permissions + from the specified entities, or removes all permissions if no entity params are specified. + required: true + choices: [present, absent] + users: + description: + - List of users to whom permission(s) should be granted + groups: + description: + - List of groups to whom permission(s) should be granted + everyone: + description: + - Apply permissions to everyone. + default: false + type: bool + permissions: + description: + - The list of permission(s) to delegate (required if C(state) is C(present)) + choices: ['allow','clone','create','destroy',...] + local: + description: + - Apply permissions to C(name) locally (C(zfs allow -l)) + default: null + type: bool + descendents: + description: + - Apply permissions to C(name)'s descendents (C(zfs allow -d)) + default: null + type: bool + recursive: + description: + - Unallow permissions recursively (ignored when C(state) is C(present)) + default: false + type: bool +author: "Nate Coraor (@natefoo)" +''' + +EXAMPLES = ''' +# Grant `zfs allow` and `unallow` permission to the `adm` user with the default local+descendents scope +- zfs_delegate_admin: name=rpool/myfs users=adm permissions=allow,unallow + +# Grant `zfs send` to everyone, plus the group `backup` +- zfs_delegate_admin: name=rpool/myvol groups=backup everyone=yes permissions=send + +# Grant `zfs send,receive` to users `foo` and `bar` with local scope only +- zfs_delegate_admin: name=rpool/myfs users=foo,bar permissions=send,receive local=yes + +# Revoke all permissions from everyone (permissions specifically assigned to users and groups remain) +- zfs_delegate_admin: name=rpool/myfs state=absent everyone=yes +''' + +# This module does not return anything other than the standard +# changed/state/msg/stdout +RETURN = ''' +''' + +from itertools import product + +from ansible.module_utils.basic import AnsibleModule + + +class ZfsDelegateAdmin(object): + def __init__(self, module): + self.module = module + self.name = module.params.get('name') + self.state = module.params.get('state') + self.users = module.params.get('users') + self.groups = module.params.get('groups') + self.everyone = module.params.get('everyone') + self.perms = module.params.get('permissions') + self.scope = None + self.changed = False + self.initial_perms = None + self.subcommand = 'allow' + self.recursive_opt = [] + self.run_method = self.update + + self.setup(module) + + def setup(self, module): + """ Validate params and set up for run. + """ + if self.state == 'absent': + self.subcommand = 'unallow' + if module.params.get('recursive'): + self.recursive_opt = ['-r'] + + local = module.params.get('local') + descendents = module.params.get('descendents') + if (local and descendents) or (not local and not descendents): + self.scope = 'ld' + elif local: + self.scope = 'l' + elif descendents: + self.scope = 'd' + else: + self.module.fail_json(msg='Impossible value for local and descendents') + + if not (self.users or self.groups or self.everyone): + if self.state == 'present': + self.module.fail_json(msg='One of `users`, `groups`, or `everyone` must be set') + elif self.state == 'absent': + self.run_method = self.clear + # ansible ensures the else cannot happen here + + self.zfs_path = module.get_bin_path('zfs', True) + + @property + def current_perms(self): + """ Parse the output of `zfs allow ` to retrieve current permissions. + """ + out = self.run_zfs_raw(subcommand='allow') + perms = { + 'l': {'u': {}, 'g': {}, 'e': []}, + 'd': {'u': {}, 'g': {}, 'e': []}, + 'ld': {'u': {}, 'g': {}, 'e': []}, + } + linemap = { + 'Local permissions:': 'l', + 'Descendent permissions:': 'd', + 'Local+Descendent permissions:': 'ld', + } + scope = None + for line in out.splitlines(): + scope = linemap.get(line, scope) + if not scope: + continue + try: + if line.startswith('\tuser ') or line.startswith('\tgroup '): + ent_type, ent, cur_perms = line.split() + perms[scope][ent_type[0]][ent] = cur_perms.split(',') + elif line.startswith('\teveryone '): + perms[scope]['e'] = line.split()[1].split(',') + except ValueError: + self.module.fail_json(msg="Cannot parse user/group permission output by `zfs allow`: '%s'" % line) + return perms + + def run_zfs_raw(self, subcommand=None, args=None): + """ Run a raw zfs command, fail on error. + """ + cmd = [self.zfs_path, subcommand or self.subcommand] + (args or []) + [self.name] + rc, out, err = self.module.run_command(cmd) + if rc: + self.module.fail_json(msg='Command `%s` failed: %s' % (' '.join(cmd), err)) + return out + + def run_zfs(self, args): + """ Run zfs allow/unallow with appropriate options as per module arguments. + """ + args = self.recursive_opt + ['-' + self.scope] + args + if self.perms: + args.append(','.join(self.perms)) + return self.run_zfs_raw(args=args) + + def clear(self): + """ Called by run() to clear all permissions. + """ + changed = False + stdout = '' + for scope, ent_type in product(('ld', 'l', 'd'), ('u', 'g')): + for ent in self.initial_perms[scope][ent_type].keys(): + stdout += self.run_zfs(['-%s' % ent_type, ent]) + changed = True + for scope in ('ld', 'l', 'd'): + if self.initial_perms[scope]['e']: + stdout += self.run_zfs(['-e']) + changed = True + return (changed, stdout) + + def update(self): + """ Update permissions as per module arguments. + """ + stdout = '' + for ent_type, entities in (('u', self.users), ('g', self.groups)): + if entities: + stdout += self.run_zfs(['-%s' % ent_type, ','.join(entities)]) + if self.everyone: + stdout += self.run_zfs(['-e']) + return (self.initial_perms != self.current_perms, stdout) + + def run(self): + """ Run an operation, return results for Ansible. + """ + exit_args = {'state': self.state} + self.initial_perms = self.current_perms + exit_args['changed'], stdout = self.run_method() + if exit_args['changed']: + exit_args['msg'] = 'ZFS delegated admin permissions updated' + exit_args['stdout'] = stdout + self.module.exit_json(**exit_args) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + name=dict(required=True), + state=dict(default='present', choices=['absent', 'present']), + users=dict(default=[], type='list'), + groups=dict(default=[], type='list'), + everyone=dict(default=False, type='bool'), + permissions=dict(default=[], type='list'), + local=dict(default=None, type='bool'), + descendents=dict(default=None, type='bool'), + recursive=dict(default=False, type='bool') + ), + supports_check_mode=False, + required_if=[('state', 'present', ['permissions'])] + ) + zfs_delegate_admin = ZfsDelegateAdmin(module) + zfs_delegate_admin.run() + + +if __name__ == '__main__': + main()