Repository: ansible/ansible

Issue:
file modules backups created while using become to root are owned by root
<!---
Verify first that your issue/request is not already reported on GitHub.
Also test if the latest release, and master branch are affected too.
-->

##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
<!--- Name of the module/plugin/task/feature -->
Template module

##### ANSIBLE VERSION
<!--- Paste verbatim output from “ansible --version” between quotes below -->
```
2.3
devel
```

##### CONFIGURATION
<!---
Mention any settings you have changed/added/removed in ansible.cfg
(or using the ANSIBLE_* environment variables).
-->

##### OS / ENVIRONMENT
RHEL 7.3

##### SUMMARY
<!--- Explain the problem briefly -->
Found the similar issue here but its closed https://github.com/ansible/ansible/issues/11886
I have a task that runs a template with become=true and backup=Yes. The owner and group is set to an account niks with a lower privilege than root. The templated file that is uploaded has the owner set to niks. But the backups are owned by root.

##### STEPS TO REPRODUCE
Attaching my playbook to reproduce with the case
<!--- Paste example playbooks or commands between quotes below -->
```yaml
---
 - name: test template
   hosts: localhost
   gather_facts: false
   become: true
   vars:
      foo: "bar"
   tasks:
     - name: template
       template:
          src=/home/niks/ansible_test/templates/test.j2
          dest=/tmp/test1.conf
          owner=niks
          group=niks
          backup=yes
```

<!--- You can also paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
<!--- What did you expect to happen when running the steps above? -->
The back up file should have permission as niks niks
##### ACTUAL RESULTS
<!--- What actually happened? If possible run with extra verbosity (-vvvv) -->
The back up file has permissions as root root
```
-rw-r--r--. 1 root root 18 May  8 18:27 /tmp/test1.conf.21163.2017-05-09@19:27:49~
-rw-r--r--. 1 niks niks 19 May  9 19:27 /tmp/test1.conf
```



Discussion:

Resolving patch:
diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py
--- a/lib/ansible/module_utils/basic.py
+++ b/lib/ansible/module_utils/basic.py
@@ -2241,7 +2241,7 @@ def backup_local(self, fn):
             backupdest = '%s.%s.%s' % (fn, os.getpid(), ext)
 
             try:
-                shutil.copy2(fn, backupdest)
+                self.preserved_copy(fn, backupdest)
             except (shutil.Error, IOError):
                 e = get_exception()
                 self.fail_json(msg='Could not make backup of %s to %s: %s' % (fn, backupdest, e))
@@ -2256,6 +2256,42 @@ def cleanup(self, tmpfile):
                 e = get_exception()
                 sys.stderr.write("could not cleanup %s: %s" % (tmpfile, e))
 
+    def preserved_copy(self, src, dest):
+        """Copy a file with preserved ownership, permissions and context"""
+
+        # shutil.copy2(src, dst)
+        #   Similar to shutil.copy(), but metadata is copied as well - in fact,
+        #   this is just shutil.copy() followed by copystat(). This is similar
+        #   to the Unix command cp -p.
+        #
+        # shutil.copystat(src, dst)
+        #   Copy the permission bits, last access time, last modification time,
+        #   and flags from src to dst. The file contents, owner, and group are
+        #   unaffected. src and dst are path names given as strings.
+
+        shutil.copy2(src, dest)
+
+        # Set the context
+        if self.selinux_enabled():
+            context = self.selinux_context(src)
+            self.set_context_if_different(dest, context, False)
+
+        # chown it
+        try:
+            dest_stat = os.stat(src)
+            tmp_stat = os.stat(dest)
+            if dest_stat and (tmp_stat.st_uid != dest_stat.st_uid or tmp_stat.st_gid != dest_stat.st_gid):
+                os.chown(dest, dest_stat.st_uid, dest_stat.st_gid)
+        except OSError as e:
+            if e.errno != errno.EPERM:
+                raise
+
+        # Set the attributes
+        current_attribs = self.get_file_attributes(src)
+        current_attribs = current_attribs.get('attr_flags', [])
+        current_attribs = ''.join(current_attribs)
+        self.set_attributes_if_different(dest, current_attribs, True)
+
     def atomic_move(self, src, dest, unsafe_writes=False):
         '''atomically move src to dest, copying attributes from dest, returns true on success
         it uses os.rename to ensure this as it is an atomic operation, rest of the function is


Test patch:
diff --git a/test/integration/targets/template/tasks/backup_test.yml b/test/integration/targets/template/tasks/backup_test.yml
new file mode 100644
--- /dev/null
+++ b/test/integration/targets/template/tasks/backup_test.yml
@@ -0,0 +1,60 @@
+# https://github.com/ansible/ansible/issues/24408
+
+- set_fact:
+    t_username: templateuser1
+    t_groupname: templateuser1
+
+- name: create the test group
+  group:
+    name: "{{ t_groupname }}"
+
+- name: create the test user
+  user:
+    name: "{{ t_username }}"
+    group: "{{ t_groupname }}"
+    createhome: no
+
+- name: set the dest file
+  set_fact:
+      t_dest: "{{ output_dir + '/tfile_dest.txt' }}"
+
+- name: create the old file
+  file:
+    path: "{{ t_dest }}"
+    state: touch
+    mode: 0777
+    owner: "{{ t_username }}"
+    group: "{{ t_groupname }}"
+
+- name: failsafe attr change incase underlying system does not support it
+  shell: chattr =j "{{ t_dest }}"
+  ignore_errors: True
+
+- name: run the template
+  template:
+    src: foo.j2
+    dest: "{{ t_dest }}"
+    backup: True
+  register: t_backup_res
+
+- name: check the data for the backup
+  stat:
+    path: "{{ t_backup_res.backup_file }}"
+  register: t_backup_stats
+
+- name: validate result of preserved backup
+  assert:
+      that:
+          - 't_backup_stats.stat.mode == "0777"'
+          - 't_backup_stats.stat.pw_name == t_username'
+          - 't_backup_stats.stat.gr_name == t_groupname'
+
+- name: cleanup the user
+  user:
+    name: "{{ t_username }}"
+    state: absent
+
+- name: cleanup the group
+  user:
+    name: "{{ t_groupname }}"
+    state: absent
diff --git a/test/integration/targets/template/tasks/main.yml b/test/integration/targets/template/tasks/main.yml
--- a/test/integration/targets/template/tasks/main.yml
+++ b/test/integration/targets/template/tasks/main.yml
@@ -369,3 +369,6 @@
     that:
         - 'diff_result.stdout == ""'
         - "diff_result.rc == 0"
+
+# aliases file requires root for template tests so this should be safe
+- include: backup_test.yml