Repository: ansible/ansible

Issue:
User locking is not idempotent
##### SUMMARY
When locking an already locked user Ansible reports a change.

##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
user.py

##### ANSIBLE VERSION
```
ansible 2.6.1
  config file = /etc/ansible/ansible.cfg
  configured module search path = [u'/home/chris/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/dist-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.12 (default, Dec  4 2017, 14:50:18) [GCC 5.4.0 20160609]
```
Bug introduced by PR #37962

##### CONFIGURATION
N/A

##### OS / ENVIRONMENT
Tested on Ubuntu 16.04, but applicable to other *nix platforms.

##### STEPS TO REPRODUCE
Run the following playbook multiple times.
```yaml
---
- hosts: localhost
  tasks:
  - user: name=testuser password_lock=yes
    become: true
```

##### EXPECTED RESULTS
The first time this is run the module reports changed. Subsequent runs shouldn't report any change, in order to remain idempotent.

##### ACTUAL RESULTS
Repeated runs of locking or unlocking the password always report changed.



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
@@ -194,7 +194,7 @@
             - Lock the password (usermod -L, pw lock, usermod -C).
               BUT implementation differs on different platforms, this option does not always mean the user cannot login via other methods.
               This option does not disable the user, only lock the password. Do not change the password in the same task.
-              Currently supported on Linux, FreeBSD, DragonFlyBSD, NetBSD.
+              Currently supported on Linux, FreeBSD, DragonFlyBSD, NetBSD, OpenBSD.
         type: bool
         version_added: "2.6"
     local:
@@ -718,9 +718,11 @@ def modify_user_usermod(self):
                     cmd.append('-e')
                     cmd.append(time.strftime(self.DATE_FORMAT, self.expires))
 
-        if self.password_lock:
+        # Lock if no password or unlocked, unlock only if locked
+        if self.password_lock and not info[1].startswith('!'):
             cmd.append('-L')
-        elif self.password_lock is not None:
+        elif self.password_lock is False and info[1].startswith('!'):
+            # usermod will refuse to unlock a user with no password, module shows 'changed' regardless
             cmd.append('-U')
 
         if self.update_password == 'always' and self.password is not None and info[1] != self.password:
@@ -1214,22 +1216,20 @@ def modify_user(self):
             return self.execute_command(cmd)
 
         # we have to lock/unlock the password in a distinct command
-        if self.password_lock:
+        if self.password_lock and not info[1].startswith('*LOCKED*'):
             cmd = [
                 self.module.get_bin_path('pw', True),
                 'lock',
-                '-n',
                 self.name
             ]
             if self.uid is not None and info[2] != int(self.uid):
                 cmd.append('-u')
                 cmd.append(self.uid)
             return self.execute_command(cmd)
-        elif self.password_lock is not None:
+        elif self.password_lock is False and info[1].startswith('*LOCKED*'):
             cmd = [
                 self.module.get_bin_path('pw', True),
                 'unlock',
-                '-n',
                 self.name
             ]
             if self.uid is not None and info[2] != int(self.uid):
@@ -1402,6 +1402,11 @@ def modify_user(self):
                 cmd.append('-L')
                 cmd.append(self.login_class)
 
+        if self.password_lock and not info[1].startswith('*'):
+            cmd.append('-Z')
+        elif self.password_lock is False and info[1].startswith('*'):
+            cmd.append('-U')
+
         if self.update_password == 'always' and self.password is not None \
                 and self.password != '*' and info[1] != self.password:
             cmd.append('-p')
@@ -1562,9 +1567,9 @@ def modify_user(self):
             cmd.append('-p')
             cmd.append(self.password)
 
-        if self.password_lock:
+        if self.password_lock and not info[1].startswith('*LOCKED*'):
             cmd.append('-C yes')
-        elif self.password_lock is not None:
+        elif self.password_lock is False and info[1].startswith('*LOCKED*'):
             cmd.append('-C no')
 
         # skip if no changes to be made


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
@@ -628,3 +628,111 @@
   file:
     path: "{{ output_dir }}/test_id_rsa"
     state: absent
+  when: ansible_os_family == 'FreeBSD'
+
+
+## password lock
+- block:
+    - name: Set password for ansibulluser
+      user:
+        name: ansibulluser
+        password: "$6$rounds=656000$TT4O7jz2M57npccl$33LF6FcUMSW11qrESXL1HX0BS.bsiT6aenFLLiVpsQh6hDtI9pJh5iY7x8J7ePkN4fP8hmElidHXaeD51pbGS."
+
+    - name: Lock account
+      user:
+        name: ansibulluser
+        password_lock: yes
+      register: password_lock_1
+
+    - name: Lock account again
+      user:
+        name: ansibulluser
+        password_lock: yes
+      register: password_lock_2
+
+    - name: Unlock account
+      user:
+        name: ansibulluser
+        password_lock: no
+      register: password_lock_3
+
+    - name: Unlock account again
+      user:
+        name: ansibulluser
+        password_lock: no
+      register: password_lock_4
+
+    - name: Ensure task reported changes appropriately
+      assert:
+        msg: The password_lock tasks did not make changes appropriately
+        that:
+          - password_lock_1 is changed
+          - password_lock_2 is not changed
+          - password_lock_3 is changed
+          - password_lock_4 is not changed
+
+    - name: Lock account
+      user:
+        name: ansibulluser
+        password_lock: yes
+
+    - name: Verify account lock for BSD
+      block:
+        - name: BSD | Get account status
+          shell: "{{ status_command[ansible_facts['system']] }}"
+          register: account_status_locked
+
+        - name: Unlock account
+          user:
+            name: ansibulluser
+            password_lock: no
+
+        - name: BSD | Get account status
+          shell: "{{ status_command[ansible_facts['system']] }}"
+          register: account_status_unlocked
+
+        - name: FreeBSD | Ensure account is locked
+          assert:
+            that:
+              - "'LOCKED' in account_status_locked.stdout"
+              - "'LOCKED' not in account_status_unlocked.stdout"
+          when: ansible_facts['system'] == 'FreeBSD'
+
+      when: ansible_facts['system'] in ['FreeBSD', 'OpenBSD']
+
+    - name: Verify account lock for Linux
+      block:
+        - name: LINUX | Get account status
+          getent:
+            database: shadow
+            key: ansibulluser
+
+        - name: LINUX | Ensure account is locked
+          assert:
+            that:
+              - getent_shadow['ansibulluser'][0].startswith('!')
+
+        - name: Unlock account
+          user:
+            name: ansibulluser
+            password_lock: no
+
+        - name: LINUX | Get account status
+          getent:
+            database: shadow
+            key: ansibulluser
+
+        - name: LINUX | Ensure account is unlocked
+          assert:
+            that:
+              - not getent_shadow['ansibulluser'][0].startswith('!')
+
+      when: ansible_facts['system'] == 'Linux'
+
+  always:
+    - name: Unlock account
+      user:
+        name: ansibulluser
+        password_lock: no
+
+  when: ansible_facts['system'] in ['FreeBSD', 'OpenBSD', 'Linux']
diff --git a/test/integration/targets/user/vars/main.yml b/test/integration/targets/user/vars/main.yml
--- a/test/integration/targets/user/vars/main.yml
+++ b/test/integration/targets/user/vars/main.yml
@@ -3,3 +3,7 @@ user_home_prefix:
   FreeBSD: '/home'
   SunOS: '/home'
   Darwin: '/Users'
+
+status_command:
+  OpenBSD: "grep ansibulluser /etc/master.passwd | cut -d ':' -f 2"
+  FreeBSD: 'pw user show ansibulluser'