diff --git a/README-group.md b/README-group.md new file mode 100644 index 0000000000000000000000000000000000000000..4609982b69cb664960b10cf0e331f2109e64eb62 --- /dev/null +++ b/README-group.md @@ -0,0 +1,153 @@ +Group module +============ + +Description +----------- + +The group module allows to add, remove, enable, disable, unlock und undelete groups. + +The group module is as compatible as possible to the Ansible upstream `ipa_group` module, but addtionally offers to add users to a group and also to remove users from a group. + + +Features +-------- +* Group management + + +Supported FreeIPA Versions +-------------------------- + +FreeIPA versions 4.4.0 and up are supported by the ipagroup module. + + +Requirements +------------ + +**Controller** +* Ansible version: 2.8+ + +**Node** +* Supported FreeIPA version (see above) + + +Usage +===== + +Example inventory file + +```ini +[ipaserver] +ipaserver.test.local +``` + + +Example playbook to add groups: + +```yaml +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Create group ops with gid 1234 + - ipagroup: + ipaadmin_password: MyPassword123 + name: ops + gidnumber: 1234 + + # Create group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + user: + - pinky + + # Create group appops + - ipagroup: + ipaadmin_password: MyPassword123 + name: appops +``` + +Example playbook to add users to a group: + +```yaml +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Add user member brain to group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + action: member + user: + - brain +``` +`action` controls if a the group or member will be handled. To add or remove members, set `action` to `member`. + + +Example playbook to add group members to a group: + +```yaml +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Add group members sysops and appops to group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: ops + group: + - sysops + - appops +``` + +Example playbook to remove groups: + +```yaml +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Remove goups sysops, appops and ops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops,appops,ops + state: absent +``` + + +Variables +========= + +ipagroup +------- + +Variable | Description | Required +-------- | ----------- | -------- +`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no +`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no +`name` \| `cn` | The list of group name strings. | no +`description` | The group description string. | no +`gid` \| `gidnumber` | The GID integer. | no +`nonposix` | Create as a non-POSIX group. (bool) | no +`external` | Allow adding external non-IPA members from trusted domains. (flag) | no +`nomembers` | Suppress processing of membership attributes. (bool) | no +`user` | List of user name strings assigned to this group. | no +`group` | List of group name strings assigned to this group. | no +`service` | List of service name strings assigned to this group | no +`action` | Work on group or member level. It can be on of `member` or `group` and defaults to `group`. | no +`state` | The state to ensure. It can be one of `present` or `absent`, defauilt: `present`. | yes + + +Authors +======= + +Thomas Woerner diff --git a/playbooks/user/add-group.yml b/playbooks/user/add-group.yml new file mode 100644 index 0000000000000000000000000000000000000000..6cb5277e49b9c15d4cc504bcfd5a4d61cf78f398 --- /dev/null +++ b/playbooks/user/add-group.yml @@ -0,0 +1,24 @@ +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Create group ops with gid 1234 + - ipagroup: + ipaadmin_password: MyPassword123 + name: ops + gidnumber: 1234 + + # Create group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + user: + - pinky + + # Create group appops + - ipagroup: + ipaadmin_password: MyPassword123 + name: appops + diff --git a/playbooks/user/add-groups-to-group.yml b/playbooks/user/add-groups-to-group.yml new file mode 100644 index 0000000000000000000000000000000000000000..6bbef720d0ffb3a68277ae206560252267453ce2 --- /dev/null +++ b/playbooks/user/add-groups-to-group.yml @@ -0,0 +1,13 @@ +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Add group members sysops and appops to group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: ops + group: + - sysops + - appops diff --git a/playbooks/user/add-user-to-group.yml b/playbooks/user/add-user-to-group.yml new file mode 100644 index 0000000000000000000000000000000000000000..e35e9c4f2cef8d5ceeb6caa82ca5a3fda5b0ff53 --- /dev/null +++ b/playbooks/user/add-user-to-group.yml @@ -0,0 +1,13 @@ +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Add user member brain to group sysops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + action: member + user: + - brain diff --git a/playbooks/user/delete-group.yml b/playbooks/user/delete-group.yml new file mode 100644 index 0000000000000000000000000000000000000000..b78055e6ce4c401a43787d1eebb4b018e924a7ad --- /dev/null +++ b/playbooks/user/delete-group.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle groups + hosts: ipaserver + become: true + + tasks: + # Remove goups sysops, appops and ops + - ipagroup: + ipaadmin_password: MyPassword123 + name: sysops,appops,ops + state: absent diff --git a/plugins/modules/ipagroup.py b/plugins/modules/ipagroup.py new file mode 100644 index 0000000000000000000000000000000000000000..1df21c6263a23544c9f681cb40cb20a4cfc9f27d --- /dev/null +++ b/plugins/modules/ipagroup.py @@ -0,0 +1,422 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Thomas Woerner <twoerner@redhat.com> +# +# Copyright (C) 2019 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program 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. +# +# This program 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 this program. If not, see <http://www.gnu.org/licenses/>. + +ANSIBLE_METADATA = { + "metadata_version": "1.0", + "supported_by": "community", + "status": ["preview"], +} + +DOCUMENTATION = """ +--- +module: ipagroup +short description: Manage FreeIPA groups +description: Manage FreeIPA groups +options: + ipaadmin_principal: + description: The admin principal + default: admin + ipaadmin_password: + description: The admin password + required: false + name: + description: The group name + required: false + aliases: ["cn"] + description: + description: The group description + required: false + gid: + description: The GID + required: false + aliases: ["gidnumber"] + nonposix: + description: Create as a non-POSIX group + required: false + type: bool + external: + description: Allow adding external non-IPA members from trusted domains + required: false + type: bool + nomembers: + description: Suppress processing of membership attributes + required: false + type: bool + user: + description: List of user names assigned to this group. + required: false + type: list + group: + description: List of group names assigned to this group. + required: false + type: list + service: + description: List of service names assigned to this group. + required: false + type: list + action: + description: Work on group or member level + default: group + choices: ["member", "group"] + state: + description: State to ensure + default: present + choices: ["present", "absent"] +author: + - Thomas Woerner +""" + +EXAMPLES = """ +# Create group ops with gid 1234 +- ipagroup: + ipaadmin_password: MyPassword123 + name: ops + gidnumber: 1234 + +# Create group sysops +- ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + +# Create group appops +- ipagroup: + ipaadmin_password: MyPassword123 + name: appops + +# Add user member pinky to group sysops +- ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + action: member + user: + - pinky + +# Add user member brain to group sysops +- ipagroup: + ipaadmin_password: MyPassword123 + name: sysops + action: member + user: + - brain + +# Add group members sysops and appops to group sysops +- ipagroup: + ipaadmin_password: MyPassword123 + name: ops + group: + - sysops + - appops + +# Remove goups sysops, appops and ops +- ipagroup: + ipaadmin_password: MyPassword123 + name: sysops,appops,ops + state: absent +""" + +RETURN = """ +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_bytes, to_native, to_text +from ansible.module_utils.ansible_freeipa_module import temp_kinit, \ + temp_kdestroy, valid_creds, api_connect, api_command, date_format, \ + compare_args_ipa + + +def find_group(module, name): + #module.warn("find_group(.., %s)" % to_text(name)) + _args = { + "all": True, + "cn": to_text(name), + } + + _result = api_command(module, "group_find", to_text(name), _args) + + if len(_result["result"]) > 1: + module.fail_json( + msg="There is more than one group '%s'" % (name)) + elif len(_result["result"]) == 1: + return _result["result"][0] + else: + return None + + +def gen_args(description, gid, nonposix, external, nomembers): + _args = {} + if description is not None: + _args["description"] = description + if gid is not None: + _args["gidnumber"] = str(gid) + if nonposix is not None: + _args["nonposix"] = nonposix + if external is not None: + _args["external"] = external + if nomembers is not None: + _args["nomembers"] = nomembers + + return _args + + +def gen_member_args(user, group, service): + _args = {} + if user is not None: + _args["member_user"] = user + if group is not None: + _args["member_group"] = group + if service is not None: + _args["member_service"] = service + + return _args + + +def main(): + ansible_module = AnsibleModule( + argument_spec=dict( + # general + ipaadmin_principal=dict(type="str", default="admin"), + ipaadmin_password=dict(type="str", required=False, no_log=True), + + name=dict(type="list", aliases=["cn"], default=None, + required=True), + # present + description=dict(type="str", default=None), + gid=dict(type="int", aliases=["gidnumber"], default=None), + nonposix=dict(required=False, type='bool', default=None), + external=dict(required=False, type='bool', default=None), + nomembers=dict(required=False, type='bool', default=None), + user=dict(required=False, type='list', default=None), + group=dict(required=False, type='list', default=None), + service=dict(required=False, type='list', default=None), + action=dict(type="str", default="group", + choices=["member", "group"]), + # state + state=dict(type="str", default="present", + choices=["present", "absent", + "member_present", "member_absent"]), + ), + supports_check_mode=True, + ) + + ansible_module._ansible_debug = True + + # Get parameters + + # general + ipaadmin_principal = ansible_module.params.get("ipaadmin_principal") + ipaadmin_password = ansible_module.params.get("ipaadmin_password") + names = ansible_module.params.get("name") + + # present + description = ansible_module.params.get("description") + gid = ansible_module.params.get("gid") + nonposix = ansible_module.params.get("nonposix") + external = ansible_module.params.get("external") + nomembers = ansible_module.params.get("nomembers") + user = ansible_module.params.get("user") + group = ansible_module.params.get("group") + service = ansible_module.params.get("service") + action = ansible_module.params.get("action") + # state + state = ansible_module.params.get("state") + + # Check parameters + + if state == "present": + if len(names) != 1: + ansible_module.fail_json( + msg="Onle one group can be added at a time.") + if action == "member": + invalid = [ "description", "gid", "nonposix", "external", + "nomembers" ] + for x in invalid: + if vars()[x] is not None: + ansible_module.fail_json( + msg="Argument '%s' can not be used with action " + "'%s'" % (x, action)) + + if state == "absent": + if len(names) < 1: + ansible_module.fail_json( + msg="No name given.") + invalid = [ "description", "gid", "nonposix", "external", "nomembers" ] + if action == "group": + invalid.extend(["user", "group", "service"]) + for x in invalid: + if vars()[x] is not None: + ansible_module.fail_json( + msg="Argument '%s' can not be used with state '%s'" % \ + (x, state)) + + # Init + + changed = False + exit_args = { } + ccache_dir = None + ccache_name = None + try: + if not valid_creds(ipaadmin_principal): + ccache_dir, ccache_name = temp_kinit(ipaadmin_principal, + ipaadmin_password) + api_connect() + + commands = [] + + for name in names: + # Make sure group exists + res_find = find_group(ansible_module, name) + #ansible_module.warn("res_find: %s" % repr(res_find)) + + # Create command + if state == "present": + # Generate args + args = gen_args(description, gid, nonposix, external, + nomembers) + + if action == "group": + # Found the group + if res_find is not None: + # For all settings is args, check if there are + # different settings in the find result. + # If yes: modify + if not compare_args_ipa(ansible_module, args, + res_find): + commands.append([name, "group_mod", args]) + else: + commands.append([name, "group_add", args]) + # Set res_find to empty dict for next step + res_find = {} + + member_args = gen_member_args(user, group, service) + if not compare_args_ipa(ansible_module, member_args, + res_find): + # Generate addition and removal lists + user_add = list( + set(user or []) - + set(res_find.get("member_user", []))) + user_del = list( + set(res_find.get("member_user", [])) - + set(user or [])) + group_add = list( + set(group or []) - + set(res_find.get("member_group", []))) + group_del = list( + set(res_find.get("member_group", [])) - + set(group or [])) + service_add = list( + set(service or []) - + set(res_find.get("member_service", []))) + service_del = list( + set(res_find.get("member_service", [])) - + set(service or [])) + + # Add members + if len(user_add) > 0 or len(group_add) > 0 or \ + len(service_add) > 0: + commands.append([name, "group_add_member", + { + "user": user_add, + "group": group_add, + "service": service_add, + }]) + # Remove members + if len(user_del) > 0 or len(group_del) > 0 or \ + len(service_del) > 0: + commands.append([name, "group_remove_member", + { + "user": user_del, + "group": group_del, + "service": service_del, + }]) + elif action == "member": + user_add = list( + set(user or []) - + set(res_find.get("member_user", []))) + group_add = list( + set(group or []) - + set(res_find.get("member_group", []))) + service_add = list( + set(service or []) - + set(res_find.get("member_service", []))) + + # Add members + if len(user_add) > 0 or len(group_add) > 0 or \ + len(service_add) > 0: + commands.append([name, "group_add_member", + { + "user": user, + "group": group, + "service": service, + }]) + + elif state == "absent": + if action == "group": + if res_find is not None: + commands.append([name, "group_del", {}]) + + elif action == "member": + # Remove intersection member + user_del = list( + set(user or []) & + set(res_find.get("member_user", []))) + group_del = list( + set(group or []) & + set(res_find.get("member_group", []))) + service_del = list( + set(service or []) & + set(res_find.get("member_service", []))) + + # Remove members + if len(user_del) > 0 or len(group_del) > 0 or \ + len(service_del) > 0: + commands.append([name, "group_remove_member", + { + "user": user, + "group": group, + "service": service, + }]) + else: + ansible_module.fail_json(msg="Unkown state '%s'" % state) + + # Execute commands + + for name, command, args in commands: + try: + result = api_command(ansible_module, command, + to_text(name), args) + changed = True + except Exception as e: + ansible_module.fail_json(msg="%s: %s: %s" % (command, name, + str(e))) + + #except Exception as e: + # ansible_module.fail_json(msg=str(e)) + + finally: + temp_kdestroy(ccache_dir, ccache_name) + + # Done + + ansible_module.exit_json(changed=changed, **exit_args) + +if __name__ == "__main__": + main()