Repository: ansible-collections/amazon.aws

Issue:
aws_s3 can't PUT when mandatory bucket-owner-full-control
##### SUMMARY
I have a S3 bucket which I upload/download/delete objects from different AWS roles.  In order to achieve this, I upload files with the *'bucket-owner-full-control'* canned ACL.  I also make the use of the *'bucket-owner-full-control'* canned ACL mandatory from the bucket's end.

With all this in place, I can upload objects from command line using `aws s3api put-object` command, but upload fails with *Access Denied* when trying to do the same using Ansible's aws_s3 module.

Also note that the PUT operation using aws_s3 successes if I retire the *'bucket-owner-full-control'* condition from the bucket.

##### ISSUE TYPE
- Bug Report

##### COMPONENT NAME
amazon.aws.aws_s3

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes -->
```paste below
$ ansible --version
ansible 2.10.3
  config file = [REDACTED]/ansible/ansible.cfg
  configured module search path = ['[REDACTED]/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = [REDACTED]/lib/python3.7/site-packages/ansible
  executable location = [REDACTED]/bin/ansible
  python version = 3.7.3 (default, Jul 25 2020, 13:03:44) [GCC 8.3.0]
```

##### CONFIGURATION
<!--- Paste verbatim output from "ansible-config dump --only-changed" between quotes -->
```paste below
$ ansible-config dump --only-changed
ANSIBLE_PIPELINING([REDACTED]/ansible/ansible.cfg) = True
ANSIBLE_SSH_RETRIES([REDACTED]/ansible/ansible.cfg) = 2
DEFAULT_LOAD_CALLBACK_PLUGINS([REDACTED]/ansible/ansible.cfg) = True
DEFAULT_ROLES_PATH([REDACTED]/ansible/ansible.cfg) = ['[REDACTED]/ansible/roles']
DEFAULT_STDOUT_CALLBACK([REDACTED]/ansible/ansible.cfg) = yaml
INTERPRETER_PYTHON([REDACTED]/ansible/ansible.cfg) = auto_silent
INVENTORY_UNPARSED_IS_FAILED([REDACTED]/ansible/ansible.cfg) = True
LOCALHOST_WARNING([REDACTED]/ansible/ansible.cfg) = False
VARIABLE_PRECEDENCE([REDACTED]/ansible/ansible.cfg) = ['all_plugins_play', 'groups_plugins_play', 'all_plugins_inventory', 'all_inventory', 'groups_plugins_inventory', 'groups_inventory']
```

##### OS / ENVIRONMENT
<!--- Provide all relevant information below, e.g. target OS versions, network device firmware, etc. -->
Working on a virtualenv, I don't think there're relevant info to put here.

##### STEPS TO REPRODUCE
bucket with policy (example; those are not the true ARNs, neither for principal nor for bucket):
```json
{
   "Version":"2012-10-17",
   "Statement":[
     {
       "Sid":"PolicyForAllowUploadWithACL",
       "Effect":"Allow",
       "Principal":{"AWS":"123456789012"},
       "Action":[
         "s3:PutObject",
         "s3:PutObjectAcl"
       ]
       "Resource":"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*",
       "Condition": {
         "StringEquals": {"s3:x-amz-acl":"bucket-owner-full-control"}
       }
     }
   ]
}
```

This code fails:
```yaml
---
- name: Test AWS Lambda uploader
  hosts: localhost
  become: no
  gather_facts: no
  
  tasks:
  - name: Asumes required role to work against AWS
    sts_assume_role:
      region: "{{ aws_region }}"
      role_arn: "{{ aws_role_arn }}"
      role_session_name: "assumedRoleSession"
    register: assumed_role
    changed_when: False
    
  - name: Uploads lambda zip to S3
    aws_s3:
      aws_access_key: "{{ assumed_role.sts_creds.access_key }}"
      aws_secret_key: "{{ assumed_role.sts_creds.secret_key }}"
      aws_security_token: "{{ assumed_role.sts_creds.session_token }}"
      region: "{{ aws_region }}"
      
      bucket: "{{ lambdas_bucket }}"
      src: "{{ lambdas_build_dir }}/{{ item.name }}.{{ item.version }}.zip"
      object: "lambdas/{{ item.name }}/{{ item.name }}.{{ item.version }}.zip"
      permission: bucket-owner-full-control
      overwrite: different
      mode: put
    loop: "{{ lambdas }}"
```
...but this code successes:
```yaml
---
- name: Test AWS Lambda uploader
  hosts: localhost
  become: no
  gather_facts: no
  
  tasks:
  - name: Asumes required role to work against AWS
    sts_assume_role:
      region: "{{ aws_region }}"
      role_arn: "{{ aws_role_arn }}"
      role_session_name: "assumedRoleSession"
    register: assumed_role
    changed_when: False
    
  - name: Uploads lambda zip to S3
    command: >-
      aws s3api put-object
        --bucket {{ lambdas_bucket }}
        --key lambdas/{{ item.name }}/{{ item.name }}.{{ item.version }}.zip
        --body {{ lambdas_build_dir }}/{{ item.name }}.{{ item.version }}.zip
        --acl bucket-owner-full-control
    environment:
      AWS_ACCESS_KEY_ID: "{{ assumed_role.sts_creds.access_key }}"
      AWS_SECRET_ACCESS_KEY: "{{ assumed_role.sts_creds.secret_key }}"
      AWS_SESSION_TOKEN: "{{ assumed_role.sts_creds.session_token }}"
      AWS_DEFAULT_REGION: "{{ aws_region }}"
    loop: "{{ lambdas }}"
```

Note that the code using the aws_s3 module also successes if I retire the *"s3:x-amz-acl":"bucket-owner-full-control"* condition from bucket.
<!--- HINT: You can paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
<!--- Describe what you expected to happen when running the steps above -->
PUT operation against S3 bucket to success.


##### ACTUAL RESULTS
<!--- Describe what actually happened. If possible run with extra verbosity (-vvvv) -->
I suspect the underlying *aws_s3* code is trying to *first* upload, *then* set permission and so, it fails, instead of doing it in one go.
<!--- Paste verbatim command output between quotes -->
```paste below
The full traceback is:
Traceback (most recent call last):
  File "[REDACTED]/lib/python3.7/site-packages/boto3/s3/transfer.py", line 279, in upload_file
    future.result()
  File "[REDACTED]/lib/python3.7/site-packages/s3transfer/futures.py", line 106, in result
    return self._coordinator.result()
  File "[REDACTED]/lib/python3.7/site-packages/s3transfer/futures.py", line 265, in result
    raise self._exception
  File "[REDACTED]/lib/python3.7/site-packages/s3transfer/tasks.py", line 126, in __call__
    return self._execute_main(kwargs)
  File "[REDACTED]/lib/python3.7/site-packages/s3transfer/tasks.py", line 150, in _execute_main
    return_value = self._main(**kwargs)
  File "[REDACTED]/lib/python3.7/site-packages/s3transfer/upload.py", line 692, in _main
    client.put_object(Bucket=bucket, Key=key, Body=body, **extra_args)
  File "[REDACTED]/lib/python3.7/site-packages/botocore/client.py", line 357, in _api_call
    return self._make_api_call(operation_name, kwargs)
  File "[REDACTED]/lib/python3.7/site-packages/botocore/client.py", line 676, in _make_api_call
    raise error_class(parsed_response, operation_name)
botocore.exceptions.ClientError: An error occurred (AccessDenied) when calling the PutObject operation: Access Denied

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "<stdin>", line 102, in <module>
  File "<stdin>", line 94, in _ansiballz_main
  File "<stdin>", line 40, in invoke_module
  File "/usr/lib/python3.7/runpy.py", line 205, in run_module
    return _run_module_code(code, init_globals, run_name, mod_spec)
  File "/usr/lib/python3.7/runpy.py", line 96, in _run_module_code
    mod_name, mod_spec, pkg_name, script_name)
  File "/usr/lib/python3.7/runpy.py", line 85, in _run_code
    exec(code, run_globals)
  File "/tmp/ansible_aws_s3_payload_6ro05161/ansible_aws_s3_payload.zip/ansible_collections/amazon/aws/plugins/modules/aws_s3.py", line 895, in <module>
  File "/tmp/ansible_aws_s3_payload_6ro05161/ansible_aws_s3_payload.zip/ansible_collections/amazon/aws/plugins/modules/aws_s3.py", line 800, in main
  File "/tmp/ansible_aws_s3_payload_6ro05161/ansible_aws_s3_payload.zip/ansible_collections/amazon/aws/plugins/modules/aws_s3.py", line 509, in upload_s3file
  File "[REDACTED]/lib/python3.7/site-packages/boto3/s3/inject.py", line 131, in upload_file
    extra_args=ExtraArgs, callback=Callback)
  File "[REDACTED]/lib/python3.7/site-packages/boto3/s3/transfer.py", line 287, in upload_file
    filename, '/'.join([bucket, key]), e))
boto3.exceptions.S3UploadFailedError: Failed to upload [REDACTED].zip to [REDACTED].zip: An error occurred (AccessDenied) when calling the PutObject operation: Access Denied                                                                                                                                                                                                  
```



Discussion:
The issue here is that Ansible is doing the upload as a two-step process (upload the file, then set the ACL).  It looks like we need to update the code to add the ACL into ExtraArgs: https://boto3.amazonaws.com/v1/documentation/api/latest/guide/s3-uploading-files.html#the-extraargs-parameter

Should someone have the time Pull Requests would be gratefully accepted, an integration test for this behaviour should also be added to the test suite.
I just ran into this bug as well, so I'm interested in tackling it. If I look at the current implementation of the `upload_s3file` method ([aws_s3.py#L514-L563](https://github.com/ansible-collections/amazon.aws/blob/045d70bb78753c98c969ee67bcbc5e034d0a7eed/plugins/modules/aws_s3.py#L514-L563)) I see that the ACLs are indeed added after the upload completes.

The problem with moving the ACLs to the `ExtraArgs` param is it will only accept a single "ACL" argument, whereas the `aws_s3` module accepts ACLs as either a single entry or a list. What does it mean to support a list of ACLs? In the current implementation, the `upload_s3file` method loops through each of the ACLs, applying them one after another. I assume that only the latest one is applied because I don't think objects can have multiple ACLs (e.g. `private` and `public-read` are mutually exclusive).

My point is, adding the ACL to the `ExtraArgs` in the `upload_file` and `upload_fileobj` method calls would require changing the current API of the `aws_s3` module to longer support lists of ACLs, which I consider a broken interpretation of S3 ACLs
Playing about a little, I believe it *is* possible to add multiple ACLs (after upload).

What I'd suggest is that you add the **first** entry from the list in ExtraArgs, keep the existing ACL logic as is (to ensure behaviour doesn't change), and document that only the first argument is used during the initial upload and the others will be added after upload.

Resolving patch:
diff --git a/plugins/modules/aws_s3.py b/plugins/modules/aws_s3.py
--- a/plugins/modules/aws_s3.py
+++ b/plugins/modules/aws_s3.py
@@ -78,7 +78,8 @@
       - This option lets the user set the canned permissions on the object/bucket that are created.
         The permissions that can be set are C(private), C(public-read), C(public-read-write), C(authenticated-read) for a bucket or
         C(private), C(public-read), C(public-read-write), C(aws-exec-read), C(authenticated-read), C(bucket-owner-read),
-        C(bucket-owner-full-control) for an object. Multiple permissions can be specified as a list.
+        C(bucket-owner-full-control) for an object. Multiple permissions can be specified as a list; although only the first one
+        will be used during the initial upload of the file
     default: ['private']
     type: list
     elements: str
@@ -531,6 +532,13 @@ def upload_s3file(module, s3, bucket, obj, expiry, metadata, encrypt, headers, s
                 else:
                     extra['Metadata'][option] = metadata[option]
 
+        if module.params.get('permission'):
+            permissions = module.params['permission']
+            if isinstance(permissions, str):
+                extra['ACL'] = permissions
+            elif isinstance(permissions, list):
+                extra['ACL'] = permissions[0]
+
         if 'ContentType' not in extra:
             content_type = None
             if src is not None:


Test patch:
diff --git a/tests/integration/targets/aws_s3/defaults/main.yml b/tests/integration/targets/aws_s3/defaults/main.yml
--- a/tests/integration/targets/aws_s3/defaults/main.yml
+++ b/tests/integration/targets/aws_s3/defaults/main.yml
@@ -1,3 +1,4 @@
 ---
 # defaults file for s3
-bucket_name: '{{resource_prefix}}'
+bucket_name: '{{ resource_prefix }}'
+bucket_name_acl: '{{ bucket_name }}-with-acl'
diff --git a/tests/integration/targets/aws_s3/tasks/main.yml b/tests/integration/targets/aws_s3/tasks/main.yml
--- a/tests/integration/targets/aws_s3/tasks/main.yml
+++ b/tests/integration/targets/aws_s3/tasks/main.yml
@@ -8,6 +8,14 @@
       region: "{{ aws_region }}"
 
   block:
+    - name: get ARN of calling user
+      aws_caller_info:
+      register: aws_caller_info
+
+    - name: register account id
+      set_fact:
+        aws_account: "{{ aws_caller_info.account }}"
+
     - name: Create temporary directory
       tempfile:
         state: directory
@@ -526,6 +534,47 @@
               - result is not changed
       when: ansible_system == 'Linux' or ansible_distribution == 'MacOSX'
 
+    - name: make a bucket with the bucket-owner-full-control ACL
+      s3_bucket:
+        name: "{{ bucket_name_acl }}"
+        state: present
+        policy: "{{ lookup('template', 'policy.json.j2') }}"
+      register: bucket_with_policy
+
+    - assert:
+        that:
+          - bucket_with_policy is changed
+
+    - name: fail to upload the file to the bucket with an ACL
+      aws_s3:
+        bucket: "{{ bucket_name_acl }}"
+        mode: put
+        src: "{{ tmpdir.path }}/upload.txt"
+        object: file-with-permissions.txt
+        permission: private
+        ignore_nonexistent_bucket: True
+      register: upload_private
+      ignore_errors: True
+
+    # XXX Doesn't fail...
+    # - assert:
+    #     that:
+    #       - upload_private is failed
+
+    - name: upload the file to the bucket with an ACL
+      aws_s3:
+        bucket: "{{ bucket_name_acl }}"
+        mode: put
+        src: "{{ tmpdir.path }}/upload.txt"
+        object: file-with-permissions.txt
+        permission: bucket-owner-full-control
+        ignore_nonexistent_bucket: True
+      register: upload_owner
+
+    - assert:
+        that:
+          - upload_owner is changed
+
     - name: create an object from static content
       aws_s3:
         bucket: "{{ bucket_name }}"
@@ -639,9 +688,22 @@
         - delete.txt
         - delete_encrypt.txt
         - delete_encrypt_kms.txt
+        - multipart.txt
         - put-content.txt
         - put-template.txt
         - put-binary.txt
+        - foo/bar/baz
+        - foo/bar
+        - foo
+      ignore_errors: yes
+
+    - name: remove uploaded files (bucket with ACL)
+      aws_s3:
+        bucket: "{{ bucket_name_acl }}"
+        mode: delobj
+        object: "{{ item }}"
+      loop:
+        - file-with-permissions.txt
       ignore_errors: yes
 
     - name: delete temporary files
@@ -661,3 +723,9 @@
         bucket: "{{ bucket_name + '.bucket' }}"
         mode: delete
       ignore_errors: yes
+
+    - name: delete the acl bucket
+      aws_s3:
+        bucket: "{{ bucket_name_acl }}"
+        mode: delete
+      ignore_errors: yes
diff --git a/tests/integration/targets/aws_s3/templates/policy.json.j2 b/tests/integration/targets/aws_s3/templates/policy.json.j2
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/aws_s3/templates/policy.json.j2
@@ -0,0 +1,21 @@
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Sid": "Only allow writes to my bucket with bucket owner full control",
+      "Effect": "Allow",
+      "Principal": { "AWS":"{{ aws_account }}" },
+      "Action": [
+        "s3:PutObject"
+      ],
+      "Resource": [
+        "arn:aws:s3:::{{ bucket_name_acl }}/*"
+      ],
+      "Condition": {
+        "StringEquals": {
+          "s3:x-amz-acl": "bucket-owner-full-control"
+        }
+      }
+    }
+  ]
+}