From a36e8e0876df51e333ab0006b0152a92adb655cc Mon Sep 17 00:00:00 2001 From: Thomas Woerner <twoerner@redhat.com> Date: Mon, 8 Jul 2019 22:43:09 +0200 Subject: [PATCH] New user management module There is a new user management module placed in the plugins folder: plugins/modules/ipauser.py The user module allows to add, remove, enable, disable, unlock und undelete users. The user module is as compatible as possible to the Ansible upstream `ipa_user` module, but addtionally offers to preserve delete, enable, disable, unlock and undelete users. Here is the documentation for the module: README-user.md New example playbooks have been added: playbooks/user/add-user.yml playbooks/user/delete-user.yml playbooks/user/enable-user.yml playbooks/user/disable-user.yml playbooks/user/delete-preserve--user.yml playbooks/user/undelete-user.yml --- README-user.md | 197 ++++++++++ playbooks/user/add-user.yml | 20 + playbooks/user/delete-preserve--user.yml | 12 + playbooks/user/delete-user.yml | 11 + playbooks/user/disable-user.yml | 11 + playbooks/user/enable-user.yml | 11 + playbooks/user/undelete-user.yml | 11 + plugins/modules/ipauser.py | 457 +++++++++++++++++++++++ 8 files changed, 730 insertions(+) create mode 100644 README-user.md create mode 100644 playbooks/user/add-user.yml create mode 100644 playbooks/user/delete-preserve--user.yml create mode 100644 playbooks/user/delete-user.yml create mode 100644 playbooks/user/disable-user.yml create mode 100644 playbooks/user/enable-user.yml create mode 100644 playbooks/user/undelete-user.yml create mode 100644 plugins/modules/ipauser.py diff --git a/README-user.md b/README-user.md new file mode 100644 index 00000000..116d55e6 --- /dev/null +++ b/README-user.md @@ -0,0 +1,197 @@ +User module +=========== + +Description +----------- + +The user module allows to add, remove, enable, disable, unlock und undelete users. + +The user module is as compatible as possible to the Ansible upstream `ipa_user` module, but addtionally offers to preserve delete, enable, disable, unlock and undelete users. + + +Features +-------- +* User management + + +Supported FreeIPA Versions +-------------------------- + +FreeIPA versions 4.4.0 and up are supported by the ipauser 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 users: + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Create user pinky + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + first: pinky + last: Acme + uid: 10001 + gid: 100 + phone: "+555123457" + email: pinky@acme.com + passwordexpiration: "2023-01-19 23:59:59" + password: "no-brain" + update_password: on_create + + # Create user brain + - ipauser: + ipaadmin_password: MyPassword123 + name: brain + first: brain + last: Acme +``` +`update_password` controls if a password for a user will be set in present state only on creation or every time (always). + + +Example playbook to delete a user, but preserve it: + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + preserve: yes + state: disabled +``` + + +Example playbook to undelete a user. + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: undeleted +``` + + +Example playbook to disable a user: + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: disabled +``` + + +Example playbook to enable a users: + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky,brain + state: disabled +``` + + +Example playbook to delete users: + +```yaml +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky,brain + state: disabled +``` + + +Variables +========= + +ipauser +------- + +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` | The list of user name strings. | no +`first` \| ¸givenname` | The first name string. | no +`last` | The last name | no +`fullname` \| `cn` | The full name string. | no +`displayname` | The display name string. | no +`homedir` | The home directory string. | no +`shell` \| `loginshell` | The login shell string. | no +`email` | List of email address strings. | no +`principalname` \| `krbprincipalname` | The kerberos principal sptring. | no +`passwordexpiration` \| `krbpasswordexpiration` | The kerberos password expiration date. Possible formats: `YYYYMMddHHmmssZ`, `YYYY-MM-ddTHH:mm:ssZ`, `YYYY-MM-ddTHH:mmZ`, `YYYY-MM-ddZ`, `YYYY-MM-dd HH:mm:ssZ` or `YYYY-MM-dd HH:mmZ`. The trailing 'Z' can be skipped. | no +`password` | The user password string. | no +`uid` \| `uidnumber` | The UID integer. | no +`gid` \| `gidnumber` | The GID integer. | no +`phone` \| `telephonenumber` | List of telephone number strings, | no +`title` | The job title string. | no +~~`sshpubkey` \| `ipasshpubkey`~~ | ~~List of SSH public keys.~~ | ~~no~~ +`update_password` | Set password for a user in present state only on creation or always. It can be one of `always` or `on_create` and defaults to `always`. | no +`preserve` | Delete a user, keeping the entry available for future use. (bool) | no +`state` | The state to ensure. It can be one of `present`, `absent`, `enabled`, `disabled`, `unlocked` or `undeleted`, default: `present`. | yes + + +Authors +======= + +Thomas Woerner diff --git a/playbooks/user/add-user.yml b/playbooks/user/add-user.yml new file mode 100644 index 00000000..72282eef --- /dev/null +++ b/playbooks/user/add-user.yml @@ -0,0 +1,20 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Create user pinky + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + first: pinky + last: Acme + uid: 10001 + gid: 100 + phone: "+555123457" + email: pinky@acme.com + passwordexpiration: "2023-01-19 23:59:59" + password: "no-brain" + update_password: on_create + diff --git a/playbooks/user/delete-preserve--user.yml b/playbooks/user/delete-preserve--user.yml new file mode 100644 index 00000000..8c9ec6de --- /dev/null +++ b/playbooks/user/delete-preserve--user.yml @@ -0,0 +1,12 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + preserve: yes + state: disabled diff --git a/playbooks/user/delete-user.yml b/playbooks/user/delete-user.yml new file mode 100644 index 00000000..71836de4 --- /dev/null +++ b/playbooks/user/delete-user.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: disabled diff --git a/playbooks/user/disable-user.yml b/playbooks/user/disable-user.yml new file mode 100644 index 00000000..71836de4 --- /dev/null +++ b/playbooks/user/disable-user.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: disabled diff --git a/playbooks/user/enable-user.yml b/playbooks/user/enable-user.yml new file mode 100644 index 00000000..71836de4 --- /dev/null +++ b/playbooks/user/enable-user.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: disabled diff --git a/playbooks/user/undelete-user.yml b/playbooks/user/undelete-user.yml new file mode 100644 index 00000000..a6cc46ec --- /dev/null +++ b/playbooks/user/undelete-user.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to handle users + hosts: ipaserver + become: true + + tasks: + # Remove user pinky and brain + - ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: undeleted diff --git a/plugins/modules/ipauser.py b/plugins/modules/ipauser.py new file mode 100644 index 00000000..d24144d2 --- /dev/null +++ b/plugins/modules/ipauser.py @@ -0,0 +1,457 @@ +#!/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: ipauser +short description: Manage FreeIPA users +description: Manage FreeIPA users +options: + ipaadmin_principal: + description: The admin principal + default: admin + ipaadmin_password: + description: The admin password + required: false + name: + description: The list of users (internally uid). + required: false + first: + description: The first name + required: false + aliases: ["givenname"] + last: + description: The last name + required: false + fullname: + description: The full name + required: false + aliases: ["cn"] + displayname: + description: The display name + required: false + homedir: + description: The home directory + required: false + shell: + description: The login shell + required: false + aliases: ["loginshell"] + email: + description: List of email addresses + required: false + principalname: + description: The kerberos principal + required: false + aliases: ["krbprincipalname"] + passwordexpiration: + description: + - The kerberos password expiration date + - (possible formats: YYYYMMddHHmmssZ, YYYY-MM-ddTHH:mm:ssZ, + - YYYY-MM-ddTHH:mmZ, YYYY-MM-ddZ, YYYY-MM-dd HH:mm:ssZ, + - YYYY-MM-dd HH:mmZ) The trailing 'Z' can be skipped. + required: false + aliases: ["krbpasswordexpiration"] + password: + description: The user password + required: false + uid: + description: The UID + required: false + aliases: ["uidnumber"] + gid: + description: The GID + required: false + aliases: ["gidnumber"] + phone: + description: List of telephone numbers + required: false + aliases: ["telephonenumber"] + title: + description: The job title + required: false + #sshpubkey: + # description: List of SSH public keys + # required: false + # aliases: ["ipasshpubkey"] + # .. + update_password: + description: Set password for a user in present state only on creation or always + default: 'always' + choices: ["always", "on_create"] + preserve: + description: Delete a user, keeping the entry available for future use + required: false + state: + description: State to ensure + default: present + choices: ["present", "absent", + "enabled", "disabled", + "unlocked", "undeleted"] +author: + - Thomas Woerner +""" + +EXAMPLES = """ +# Create user pinky +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky + first: pinky + last: Acme + uid: 10001 + gid: 100 + phone: "+555123457" + email: pinky@acme.com + passwordexpiration: "2023-01-19 23:59:59" + password: "no-brain" + update_password: on_create + +# Create user brain +- ipauser: + ipaadmin_password: MyPassword123 + name: brain + first: brain + last: Acme + +# Delete user pinky, but preserved +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky + preserve: yes + state: absent + +# Undelete user pinky +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky + state: undeleted + +# Disable user pinky +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky,brain + state: disabled + +# Enable user pinky and brain +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky,brain + state: enabled + +# Remove user pinky and brain +- ipauser: + ipaadmin_password: MyPassword123 + name: pinky,brain + state: disabled +""" + +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_user(module, name, preserved=False): + #module.warn("find_user(.., %s)" % to_text(name)) + _args = { + "all": True, + "uid": to_text(name), + } + if preserved: + _args["preserved"] = preserved + + _result = api_command(module, "user_find", to_text(name), _args) + + if len(_result["result"]) > 1: + module.fail_json( + msg="There is more than one user '%s'" % (name)) + elif len(_result["result"]) == 1: + return _result["result"][0] + else: + return None + +def gen_args(first, last, fullname, displayname, homedir, shell, emails, + principalname, passwordexpiration, password, uid, gid, + phones, title, sshpubkey): + _args = {} + if first is not None: + _args["givenname"] = first + if last is not None: + _args["sn"] = last + if fullname is not None: + _args["cn"] = fullname + if displayname is not None: + _args["displayname"] = displayname + if homedir is not None: + _args["homedirectory"] = homedir + if shell is not None: + _args["loginshell"] = shell + if emails is not None and len(emails) > 0: + _args["mail"] = emails + if principalname is not None: + _args["krbprincipalname"] = principalname + if passwordexpiration is not None: + _args["krbpasswordexpiration"] = passwordexpiration + if password is not None: + _args["userpassword"] = password + if uid is not None: + _args["uidnumber"] = str(uid) + if gid is not None: + _args["gidnumber"] = str(gid) + if phones is not None and len(phones) > 0: + _args["telephonenumber"] = phones + if title is not None: + _args["title"] = title + if sshpubkey is not None: + _args["ipasshpubkey"] = sshpubkey + + 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=["login"], default=None, + required=True), + # present + first=dict(type="str", aliases=["givenname"], default=None), + last=dict(type="str", default=None), + fullname=dict(type="str", aliases=["cn"], default=None), + displayname=dict(type="str", default=None), + homedir=dict(type="str", default=None), + shell=dict(type="str", aliases=["loginshell"], default=None), + email=dict(type="list", default=None), + principalname=dict(type="str", aliases=["krbprincipalname"], + default=None), + passwordexpiration=dict(type="str", + aliases=["krbpasswordexpiration"], + default=None), + password=dict(type="str", default=None, no_log=True), + uid=dict(type="int", aliases=["uidnumber"], default=None), + gid=dict(type="int", aliases=["gidnumber"], default=None), + phone=dict(type="list", aliases=["telephonenumber"], default=None), + title=dict(type="str", default=None), + #sshpubkey=dict(type="list", aliases=["ipasshpubkey"], + # default=None), + update_password=dict(type='str', default=None, + choices=['always', 'on_create']), + # deleted + preserve=dict(required=False, type='bool', default=None), + # state + state=dict(type="str", default="present", + choices=["present", "absent", "enabled", "disabled", + "unlocked", "undeleted"]), + ), + 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 + first = ansible_module.params.get("first") + last = ansible_module.params.get("last") + fullname = ansible_module.params.get("fullname") + displayname = ansible_module.params.get("displayname") + homedir = ansible_module.params.get("homedir") + shell = ansible_module.params.get("shell") + emails = ansible_module.params.get("email") + principalname = ansible_module.params.get("principalname") + passwordexpiration = ansible_module.params.get("passwordexpiration") + if passwordexpiration is not None: + if passwordexpiration[:-1] != "Z": + passwordexpiration = "%sZ" % passwordexpiration + passwordexpiration = date_format(passwordexpiration) + password = ansible_module.params.get("password") + uid = ansible_module.params.get("uid") + gid = ansible_module.params.get("gid") + phones = ansible_module.params.get("phone") + title = ansible_module.params.get("title") + sshpubkey = ansible_module.params.get("sshpubkey") + update_password = ansible_module.params.get("update_password") + # deleted + preserve = ansible_module.params.get("preserve") + # state + state = ansible_module.params.get("state") + + # Check parameters + + if state == "present": + if len(names) != 1: + ansible_module.fail_json( + msg="Onle one user can be added at a time.") + if first is None: + ansible_module.fail_json(msg="First name is needed") + if last is None: + ansible_module.fail_json(msg="Last name is needed") + + if state == "absent": + if len(names) < 1: + ansible_module.fail_json( + msg="No name given.") + for x in [ "first", "last", "fullname", "displayname", "homedir", + "shell", "emails", "principalname", "passwordexpiration", + "password", "uid", "gid", "phones", "title", "sshpubkey", + "update_password" ]: + if vars()[x] is not None: + ansible_module.fail_json( + msg="Argument '%s' can not be used with state '%s'" % \ + (x, state)) + else: + if preserve is not None: + ansible_module.fail_json( + msg="Preserve is only possible for state=absent") + + if update_password is None: + update_password = "always" + + # 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 user exists + res_find = find_user(ansible_module, name) + # Also search for preserved user + res_find_preserved = find_user(ansible_module, name, + preserved=True) + #ansible_module.warn("res_find: %s" % repr(res_find)) + + # Create command + if state == "present": + # Generate args + args = gen_args( + first, last, fullname, displayname, homedir, shell, emails, + principalname, passwordexpiration, password, uid, gid, + phones, title, sshpubkey) + + # Also check preserved users + if res_find is None and res_find_preserved is not None: + res_find = res_find_preserved + + # Found the user + if res_find is not None: + # Ignore password with update_password == on_create + if update_password == "on_create" and \ + "userpassword" in args: + del args["userpassword"] + + # 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, "user_mod", args]) + else: + commands.append([name, "user_add", args]) + + elif state == "absent": + # Also check preserved users + if res_find is None and res_find_preserved is not None: + res_find = res_find_preserved + + if res_find is not None: + args = {} + if preserve is not None: + args["preserve"] = preserve + commands.append([name, "user_del", args]) + + elif state == "undeleted": + if res_find_preserved is not None: + commands.append([name, "user_undel", {}]) + else: + raise ValueError("No preserved user '%s'" % name) + + elif state == "enabled": + if res_find is not None: + if res_find["nsaccountlock"] == True: + commands.append([name, "user_enable", {}]) + else: + raise ValueError("No disabled user '%s'" % name) + + elif state == "disabled": + if res_find is not None: + if res_find["nsaccountlock"] == False: + commands.append([name, "user_disable", {}]) + else: + raise ValueError("No user '%s'" % name) + + elif state == "unlocked": + if res_find is not None: + commands.append([name, "user_unlock", {}]) + + 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() -- GitLab