Repository: ansible/ansible

Issue:
core-module 'user' with option 'expires' - cannot "unexpire" accounts with "-1"
##### ISSUE TYPE
 - Feature Idea

##### COMPONENT NAME
ansible-modules-core/system/user.py

##### ANSIBLE VERSION
```
ansible 2.1.1.0
```

##### CONFIGURATION

##### OS / ENVIRONMENT
Ubuntu 16.04

##### SUMMARY
The module user is supposed to be based on system commands "useradd, userdel and usermod". It has no account locking function because people discussed that an expire date option would be better. So to disable a bunch of users I can use 'expires' with time in epoch format, but ...

.. the problem is, **that you cannot reset the expire date (disable expiry)** as you could with the system commands. Usually, you can use the expiredate option of e.g. adduser and set it to "-1" or nothing (emtpy string):

The man pages for adduser/useradd say: "By default, **the password expiry value set to -1** means never expire." Furthermore, adduser allows to set an empty string: "If not specified, useradd will use the default expiry date specified by the EXPIRE variable in /etc/default/useradd, **or an empty string (no expiry)** by default."

Since this module uses system commands, I would expect it to offer similiar functionality but it does not:
- if you set expires=-1 it does not change anything at all, account stays expired. YOu can't use an empty string either: -> "msg": "argument expires is of type <type 'str'> and we were unable to convert to float"

In terms of shell you can easily unset expire date with: usermod USER -e -1

I would suggest that it should be able to use the module "user" as you would use adduser/moduser option --expiredate. Until now, you would have to use the command/shell/raw module. It would make sense to me to enable this for the user module.

##### STEPS TO REPRODUCE

First, expire a user:

```yaml
---
- name: test expire
  hosts: all
  become: yes
  vars_files:
    - /etc/ansible/secrets.yml
  tasks:
    - name: Disable user accounts with expires
      user: name={{ item.name }} expires=1

      with_items:
        - { name: 'USER' }
```
Now, "unexpire" the user with expires=-1  or expires="" or even expires=

```yaml
---
- name: test expire
  hosts: all
  become: yes
  vars_files:
    - /etc/ansible/secrets.yml
  tasks:
    - name: Disable user accounts with expires
      user: name={{ item.name }} expires=-1

      with_items:
        - { name: 'USER' }
```
##### EXPECTED RESULTS

It should re-enable the user account, unsetting any expire date as it would with usermod/moduser

##### ACTUAL RESULTS

With "-1" ansible tells you "changed: [HOST] => (item={u'name': u'USER'} but it has changed nothing:
Your account has expired; please contact your system administrator

If you use an empty string for expire, it fails:
failed: [HOST] (item={u'name': u'USER'}) => {"failed": true, "item": {"name": "USER"}, "msg": "argument expires is of type <type 'str'> and we were unable to convert to float"




Discussion:

Resolving patch:
diff --git a/lib/ansible/modules/system/user.py b/lib/ansible/modules/system/user.py
--- a/lib/ansible/modules/system/user.py
+++ b/lib/ansible/modules/system/user.py
@@ -32,12 +32,12 @@
             - 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 @@
         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 @@
               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 @@
     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"
-    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).
+            - 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, 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 @@
     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 @@
     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 @@ def __init__(self, module):
         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 @@ def create_user_useradd(self):
             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 @@ def modify_user_usermod(self):
             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 @@ def user_password(self):
                 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 @@ def create_user(self):
             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 @@ def modify_user(self):
                     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:
+
+            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)
 
-            # 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 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):


Test patch:
diff --git a/test/integration/targets/user/tasks/main.yml b/test/integration/targets/user/tasks/main.yml
--- 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'