From 677fe1076dce6f4a0d8567dbd8dd4fc8691981f0 Mon Sep 17 00:00:00 2001
From: Brian Coca <bcoca@users.noreply.github.com>
Date: Thu, 17 May 2018 11:34:13 -0400
Subject: [PATCH] User unexpire (#39758)

* Allow negative values to expires to unexpire a user

Fixes #20096

(cherry picked from commit 34f8080a19c09cd20ec9c045fca1e37ef74bb1e6)
(cherry picked from commit 54619f70f4b79f121c5062d54e9732d3cbb24377)
(cherry picked from commit 8c2fae27d6e2af810112032bb1dfef5459035b7e)
(cherry picked from commit db1a32f8caa8c8b9f989baa65784d4b2b5cad1f8)

* tweaked and normalized

 - also added tests, made checking resilient
---
 lib/ansible/modules/system/user.py           | 109 +++++++++++--------
 test/integration/targets/user/tasks/main.yml |  53 +++++++++
 2 files changed, 116 insertions(+), 46 deletions(-)

diff --git a/lib/ansible/modules/system/user.py b/lib/ansible/modules/system/user.py
index 2dd68c7937a..a1bfa3a8943 100644
--- a/lib/ansible/modules/system/user.py
+++ b/lib/ansible/modules/system/user.py
@@ -32,12 +32,12 @@ options:
             - Name of the user to create, remove or modify.
         required: true
         aliases: [ user ]
-    comment:
-        description:
-            - Optionally sets the description (aka I(GECOS)) of user account.
     uid:
         description:
             - Optionally sets the I(UID) of the user.
+    comment:
+        description:
+            - Optionally sets the description (aka I(GECOS)) of user account.
     hidden:
         required: false
         type: bool
@@ -47,8 +47,7 @@ options:
         version_added: "2.6"
     non_unique:
         description:
-            - Optionally when used with the -u option, this option allows to
-              change the user ID to a non-unique value.
+            - Optionally when used with the -u option, this option allows to change the user ID to a non-unique value.
         type: bool
         default: "no"
         version_added: "1.1"
@@ -67,16 +66,14 @@ options:
               now it should be able to accept YAML lists also.
     append:
         description:
-            - If C(yes), will only add groups, not set them to just the list
-              in I(groups).
+            - If C(yes), will only add groups, not set them to just the list in I(groups).
         type: bool
         default: "no"
     shell:
         description:
             - Optionally set the user's shell.
-            - On Mac OS X, before version 2.5, the default shell for non-system users was
-              /usr/bin/false. Since 2.5, the default shell for non-system users on
-              Mac OS X is /bin/bash.
+            - On Mac OS X, before version 2.5, the default shell for non-system users was /usr/bin/false.
+              Since 2.5, the default shell for non-system users on Mac OS X is /bin/bash.
     home:
         description:
             - Optionally set the user's home directory.
@@ -98,39 +95,38 @@ options:
     create_home:
         description:
             - Unless set to C(no), a home directory will be made for the user
-              when the account is created or if the home directory does not
-              exist.
+              when the account is created or if the home directory does not exist.
             - Changed from C(createhome) to C(create_home) in version 2.5.
         type: bool
         default: 'yes'
         aliases: ['createhome']
     move_home:
         description:
-            - If set to C(yes) when used with C(home=), attempt to move the
-              user's home directory to the specified directory if it isn't there
-              already.
+            - If set to C(yes) when used with C(home=), attempt to move the user's old home
+              directory to the specified directory if it isn't there already and the old home exists.
         type: bool
         default: "no"
     system:
         description:
-            - When creating an account, setting this to C(yes) makes the user a
-              system account.  This setting cannot be changed on existing users.
+            - When creating an account C(state=present), setting this to C(yes) makes the user a system account.
+              This setting cannot be changed on existing users.
         type: bool
         default: "no"
     force:
         description:
-            - When used with C(state=absent), behavior is as with C(userdel --force).
+            - This only affects C(state=absent), it forces removal of the user and associated directories on supported platforms.
+              The behavior is the same as C(userdel --force), check the man page for C(userdel) on your system for details and support.
+        type: bool
+        default: "no"
+    remove:
+        description:
+            - This only affects C(state=absent), it attempts to remove directories associated with the user.
+              The behavior is the same as C(userdel --remove), check the man page for details and support.
         type: bool
         default: "no"
     login_class:
         description:
-            - Optionally sets the user's login class for FreeBSD, DragonFlyBSD, OpenBSD and
-              NetBSD systems.
-    remove:
-        description:
-            - When used with C(state=absent), behavior is as with C(userdel --remove).
-        type: bool
-        default: "no"
+            - Optionally sets the user's login class, a feature of most BSD OSs.
     generate_ssh_key:
         description:
             - Whether to generate a SSH key for the user in question.
@@ -176,7 +172,8 @@ options:
     expires:
         description:
             - An expiry time for the user in epoch, it will be ignored on platforms that do not support this.
-              Currently supported on Linux, FreeBSD, and DragonFlyBSD.
+              Currently supported on GNU/Linux, FreeBSD, and DragonFlyBSD.
+            - Since version 2.6 you can remove the expiry time specify a negative value. Currently supported on GNU/Linux and FreeBSD.
         version_added: "1.9"
     password_lock:
         description:
@@ -231,6 +228,12 @@ EXAMPLES = '''
     shell: /bin/zsh
     groups: developers
     expires: 1422403387
+
+- name: starting at version 2.6, modify user, remove expiry time
+  user:
+    name: james18
+    expires: -1
+
 '''
 
 import grp
@@ -311,11 +314,11 @@ class User(object):
         if module.params['groups'] is not None:
             self.groups = ','.join(module.params['groups'])
 
-        if module.params['expires']:
+        if module.params['expires'] is not None:
             try:
                 self.expires = time.gmtime(module.params['expires'])
             except Exception as e:
-                module.fail_json(msg="Invalid expires time %s: %s" % (self.expires, to_native(e)))
+                module.fail_json(msg="Invalid value for 'expires' %s: %s" % (self.expires, to_native(e)))
 
         if module.params['ssh_key_file'] is not None:
             self.ssh_file = module.params['ssh_key_file']
@@ -409,7 +412,7 @@ class User(object):
             cmd.append('-s')
             cmd.append(self.shell)
 
-        if self.expires:
+        if self.expires is not None:
             cmd.append('-e')
             cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
 
@@ -532,17 +535,22 @@ class User(object):
             cmd.append('-s')
             cmd.append(self.shell)
 
-        if self.expires:
-            current_expires = self.user_password()[1]
+        if self.expires is not None:
 
-            # Convert days since Epoch to seconds since Epoch as struct_time
-            total_seconds = int(current_expires) * 86400
-            current_expires = time.gmtime(total_seconds)
+            current_expires = int(self.user_password()[1])
 
-            # Compare year, month, and day only
-            if current_expires[:3] != self.expires[:3]:
-                cmd.append('-e')
-                cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
+            if self.expires < time.gmtime(0):
+                if current_expires > 0:
+                    cmd.append('-e')
+                    cmd.append('')
+            else:
+                # Convert days since Epoch to seconds since Epoch as struct_time
+                current_expire_date = time.gmtime(current_expires * 86400)
+
+                # Current expires is negative or we compare year, month, and day only
+                if current_expires <= 0 or current_expire_date[:3] != self.expires[:3]:
+                    cmd.append('-e')
+                    cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
 
         if self.password_lock:
             cmd.append('-L')
@@ -647,7 +655,7 @@ class User(object):
                 for line in open(self.SHADOWFILE).readlines():
                     if line.startswith('%s:' % self.name):
                         passwd = line.split(':')[1]
-                        expires = line.split(':')[self.SHADOWFILE_EXPIRE_INDEX]
+                        expires = line.split(':')[self.SHADOWFILE_EXPIRE_INDEX] or -1
         return passwd, expires
 
     def get_ssh_key_path(self):
@@ -845,7 +853,7 @@ class FreeBsdUser(User):
             cmd.append('-L')
             cmd.append(self.login_class)
 
-        if self.expires:
+        if self.expires is not None:
             cmd.append('-e')
             cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
 
@@ -946,13 +954,22 @@ class FreeBsdUser(User):
                     new_groups = groups | set(current_groups)
                 cmd.append(','.join(new_groups))
 
-        if self.expires:
-            current_expires = time.gmtime(int(self.user_password()[1]))
+        if self.expires is not None:
 
-            # Compare year, month, and day only
-            if current_expires[:3] != self.expires[:3]:
-                cmd.append('-e')
-                cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
+            current_expires = int(self.user_password()[1])
+
+            if self.expires < time.gmtime(0):
+                if current_expires > 0:
+                    cmd.append('-e')
+                    cmd.append('0')
+            else:
+                # Convert days since Epoch to seconds since Epoch as struct_time
+                current_expire_date = time.gmtime(current_expires)
+
+                # Current expires is negative or we compare year, month, and day only
+                if current_expires <= 0 or current_expire_date[:3] != self.expires[:3]:
+                    cmd.append('-e')
+                    cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
 
         # modify the user if cmd will do anything
         if cmd_len != len(cmd):
diff --git a/test/integration/targets/user/tasks/main.yml b/test/integration/targets/user/tasks/main.yml
index c013a1d5b1d..ee20aac5bea 100644
--- a/test/integration/targets/user/tasks/main.yml
+++ b/test/integration/targets/user/tasks/main.yml
@@ -246,3 +246,56 @@
     - name: Restore original timezone - {{ original_timezone.diff.before.name }}
       timezone:
         name: "{{ original_timezone.diff.before.name }}"
+
+
+- name: Unexpire user
+  user:
+    name: ansibulluser
+    state: present
+    expires: -1
+  register: user_test_expires3
+
+- name: Verify un expiration date for Linux
+  block:
+    - name: LINUX | Get expiration date for ansibulluser
+      getent:
+        database: shadow
+        key: ansibulluser
+
+    - name: LINUX | Ensure proper expiration date was set
+      assert:
+        msg: "expiry is supposed to be empty or -1, not {{getent_shadow['ansibulluser'][6]}}"
+        that:
+          - not getent_shadow['ansibulluser'][6] or getent_shadow['ansibulluser'][6] < 0
+  when: ansible_os_family in ['RedHat', 'Debian', 'Suse']
+
+- name: Verify un expiration date for linux/BSD
+  block:
+    - name: Unexpire user again to check for change
+      user:
+        name: ansibulluser
+        state: present
+        expires: -1
+      register: user_test_expires4
+
+    - name: Ensure first expiration reported a change and second did not
+      assert:
+        msg: The second run of the expiration removal task reported a change when it should not
+        that:
+          - user_test_expires3 is changed
+          - user_test_expires4 is not changed
+  when: ansible_os_family in ['RedHat', 'Debian', 'Suse', 'FreeBSD']
+
+- name: Verify un expiration date for BSD
+  block:
+    - name: BSD | Get expiration date for ansibulluser
+      shell: 'grep ansibulluser /etc/master.passwd | cut -d: -f 7'
+      changed_when: no
+      register: bsd_account_expiration
+
+    - name: BSD | Ensure proper expiration date was set
+      assert:
+        msg: "expiry is supposed to be '0', not {{bsd_account_expiration.stdout}}"
+        that:
+          - bsd_account_expiration.stdout == '0'
+  when: ansible_os_family == 'FreeBSD'