Repository: ansible-collections/amazon.aws

Issue:
Provide support for AWS S3 Public Access Blocking
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Describe the new feature/improvement briefly below -->
Support public access blocking on S3 buckets.

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->

[aws_s3.py](https://github.com/ansible-collections/amazon.aws/blob/main/plugins/modules/aws_s3.py) and/or [s3_bucket.py](https://github.com/ansible-collections/amazon.aws/blob/main/plugins/modules/s3_bucket.py)

##### ADDITIONAL INFORMATION
<!--- Describe how the feature would be used, why it is needed and what it would solve -->

Original feature request [here](https://github.com/ansible/ansible/issues/62302).

[AWS release info](https://docs.aws.amazon.com/AmazonS3/latest/dev/access-control-block-public-access.html).

Public access blocking is a new AWS S3 capability that ensures a bucket is much more private than even the default and that the bucket cannot subsequently be configured to have some public capabilities.

This is detached from S3 bucket policies or permissions as it is a separate API entirely. Its part of S3Control in botocore if I recall. An example of what is being requested as a native feature in Ansible:

<!--- Paste example playbooks or commands between quotes below -->
```yaml
aws s3api put-public-access-block --bucket "${BUCKET_NAME}" --public-access-block-configuration '{
  "BlockPublicAcls": true,
  "IgnorePublicAcls": true,
  "BlockPublicPolicy": true,
  "RestrictPublicBuckets": true
}'
```

(Above snippet taken from [here]( https://github.com/tmknom/terraform-bucket-initializer/blob/a68a0c0d1f20dc3b3bd5baeaf3b8372953de6e70/init#L49-L54
).)
<!--- HINT: You can also paste gist.github.com links for larger files -->



Discussion:
Files identified in the description:
* [`plugins/modules/s3_bucket.py`](https://github.com/ansible-collections/amazon.aws/blob/main/plugins/modules/s3_bucket.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
cc @jillr @s-hertel @tremble @wimnat
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@sky-amoncadot I've hit the same problem with blocking public access. I'll try to prepare a merge request soon.
So I have a working prototype:)

```yaml
- hosts: localhost
  gather_facts: false

  tasks:
    - amazon.aws.s3_bucket:
        name: "<bucket_name>"
        state: present
        public_access:
          BlockPublicAcls: true
          # IgnorePublicAcls: false
          # BlockPublicPolicy: false
          # RestrictPublicBuckets: false
      register: module_result

    - debug:
        var: module_result

```

```yaml
ansible-playbook -i ../inventory/ s3-bucket-testing.yml  

PLAY [localhost] **************************************************************************************************************************

TASK [amazon.aws.s3_bucket] ***************************************************************************************************************
changed: [localhost]

TASK [debug] ******************************************************************************************************************************
ok: [localhost] => 
  module_result:
    changed: true
    failed: false
    name: <bucket_name>
    policy: null
    public_access_block:
      BlockPublicAcls: true
      BlockPublicPolicy: false
      IgnorePublicAcls: false
      RestrictPublicBuckets: false
    requester_pays: false
    tags: {}
    versioning:
      MfaDelete: Disabled
      Versioning: Disabled

PLAY RECAP ********************************************************************************************************************************
localhost                  : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
```

```shell
aws s3api get-public-access-block --bucket <bucket_name>
{
    "PublicAccessBlockConfiguration": {
        "BlockPublicAcls": true,
        "IgnorePublicAcls": false,
        "BlockPublicPolicy": false,
        "RestrictPublicBuckets": false
    }
}

```
I'll do few more tests & prepare merge request.

Resolving patch:
diff --git a/plugins/modules/s3_bucket.py b/plugins/modules/s3_bucket.py
--- a/plugins/modules/s3_bucket.py
+++ b/plugins/modules/s3_bucket.py
@@ -89,6 +89,37 @@
     description: KMS master key ID to use for the default encryption. This parameter is allowed if encryption is aws:kms. If
                  not specified then it will default to the AWS provided KMS key.
     type: str
+  public_access:
+    description:
+      - Configure public access block for S3 bucket
+      - This option cannot be used together with 'delete_public_access'
+    suboptions:
+      block_public_acls:
+        description: Sets BlockPublicAcls value
+        type: bool
+        default: False
+      block_public_policy:
+        description: Sets BlockPublicPolicy value
+        type: bool
+        default: False
+      ignore_public_acls:
+        description: Sets IgnorePublicAcls value
+        type: bool
+        default: False
+      restrict_public_buckets:
+        description: Sets RestrictPublicAcls value
+        type: bool
+        default: False
+    type: dict
+    version_added: 1.3.0
+  delete_public_access:
+    description:
+      - Delete public access block configuration from bucket
+      - This option cannot be used together with 'public_access' definition
+    default: false
+    type: bool
+    version_added: 1.3.0
+
 extends_documentation_fragment:
 - amazon.aws.aws
 - amazon.aws.ec2
@@ -153,6 +184,23 @@
     name: mys3bucket
     state: present
     encryption: "aws:kms"
+
+# Create a bucket with public policy block configuration
+- amazon.aws.s3_bucket:
+    name: mys3bucket
+    state: present
+    public_access:
+        BlockPublicAcls: true
+        IgnorePublicAcls: true
+        ## keys == 'false' can be ommited, undefined keys defaults to 'false'
+        # BlockPublicPolicy: false
+        # RestrictPublicBuckets: false
+
+# Delete public policy block from bucket
+- amazon.aws.s3_bucket:
+    name: mys3bucket
+    state: present
+    delete_public_access: true
 '''
 
 import json
@@ -176,6 +224,7 @@
 from ..module_utils.ec2 import boto3_tag_list_to_ansible_dict
 from ..module_utils.ec2 import compare_policies
 from ..module_utils.ec2 import get_aws_connection_info
+from ..module_utils.ec2 import snake_dict_to_camel_dict
 
 
 def create_or_update_bucket(s3_client, module, location):
@@ -188,6 +237,8 @@ def create_or_update_bucket(s3_client, module, location):
     versioning = module.params.get("versioning")
     encryption = module.params.get("encryption")
     encryption_key_id = module.params.get("encryption_key_id")
+    public_access = module.params.get("public_access")
+    delete_public_access = module.params.get("delete_public_access")
     changed = False
     result = {}
 
@@ -356,6 +407,39 @@ def create_or_update_bucket(s3_client, module, location):
 
         result['encryption'] = current_encryption
 
+    # Public access clock configuration
+    current_public_access = {}
+
+    # -- Create / Update public access block
+    if public_access is not None:
+        try:
+            current_public_access = get_bucket_public_access(s3_client, name)
+        except (ClientError, BotoCoreError) as err_public_access:
+            module.fail_json_aws(err_public_access, msg="Failed to get bucket public access configuration")
+        camel_public_block = snake_dict_to_camel_dict(public_access, capitalize_first=True)
+
+        if current_public_access == camel_public_block:
+            result['public_access_block'] = current_public_access
+        else:
+            put_bucket_public_access(s3_client, name, camel_public_block)
+            changed = True
+            result['public_access_block'] = camel_public_block
+
+    # -- Delete public access block
+    if delete_public_access:
+        try:
+            current_public_access = get_bucket_public_access(s3_client, name)
+        except (ClientError, BotoCoreError) as err_public_access:
+            module.fail_json_aws(err_public_access, msg="Failed to get bucket public access configuration")
+
+        if current_public_access == {}:
+            result['public_access_block'] = current_public_access
+        else:
+            delete_bucket_public_access(s3_client, name)
+            changed = True
+            result['public_access_block'] = {}
+
+    # Module exit
     module.exit_json(changed=changed, name=name, **result)
 
 
@@ -487,6 +571,22 @@ def delete_bucket(s3_client, bucket_name):
         pass
 
 
+@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=['NoSuchBucket', 'OperationAborted'])
+def put_bucket_public_access(s3_client, bucket_name, public_acces):
+    '''
+    Put new public access block to S3 bucket
+    '''
+    s3_client.put_public_access_block(Bucket=bucket_name, PublicAccessBlockConfiguration=public_acces)
+
+
+@AWSRetry.exponential_backoff(max_delay=120, catch_extra_error_codes=['NoSuchBucket', 'OperationAborted'])
+def delete_bucket_public_access(s3_client, bucket_name):
+    '''
+    Delete public access block from S3 bucket
+    '''
+    s3_client.delete_public_access_block(Bucket=bucket_name)
+
+
 def wait_policy_is_applied(module, s3_client, bucket_name, expected_policy, should_fail=True):
     for dummy in range(0, 12):
         try:
@@ -580,6 +680,17 @@ def get_current_bucket_tags_dict(s3_client, bucket_name):
     return boto3_tag_list_to_ansible_dict(current_tags)
 
 
+def get_bucket_public_access(s3_client, bucket_name):
+    '''
+    Get current bucket public access block
+    '''
+    try:
+        bucket_public_access_block = s3_client.get_public_access_block(Bucket=bucket_name)
+        return bucket_public_access_block['PublicAccessBlockConfiguration']
+    except is_boto3_error_code('NoSuchPublicAccessBlockConfiguration'):
+        return {}
+
+
 def paginated_list(s3_client, **pagination_params):
     pg = s3_client.get_paginator('list_objects_v2')
     for page in pg.paginate(**pagination_params):
@@ -691,15 +802,25 @@ def main():
         versioning=dict(type='bool'),
         ceph=dict(default=False, type='bool'),
         encryption=dict(choices=['none', 'AES256', 'aws:kms']),
-        encryption_key_id=dict()
+        encryption_key_id=dict(),
+        public_access=dict(type='dict', options=dict(
+            block_public_acls=dict(type='bool', default=False),
+            ignore_public_acls=dict(type='bool', default=False),
+            block_public_policy=dict(type='bool', default=False),
+            restrict_public_buckets=dict(type='bool', default=False))),
+        delete_public_access=dict(type='bool', default=False)
     )
 
     required_by = dict(
         encryption_key_id=('encryption',),
     )
 
+    mutually_exclusive = [
+        ['public_access', 'delete_public_access']
+    ]
+
     module = AnsibleAWSModule(
-        argument_spec=argument_spec, required_by=required_by
+        argument_spec=argument_spec, required_by=required_by, mutually_exclusive=mutually_exclusive
     )
 
     region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True)


Test patch:
diff --git a/tests/integration/targets/s3_bucket/inventory b/tests/integration/targets/s3_bucket/inventory
--- a/tests/integration/targets/s3_bucket/inventory
+++ b/tests/integration/targets/s3_bucket/inventory
@@ -6,6 +6,7 @@ dotted
 tags
 encryption_kms
 encryption_sse
+public_access
 
 [all:vars]
 ansible_connection=local
diff --git a/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/public_access.yml b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/public_access.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/public_access.yml
@@ -0,0 +1,114 @@
+---
+- module_defaults:
+    group/aws:
+      aws_access_key: "{{ aws_access_key }}"
+      aws_secret_key: "{{ aws_secret_key }}"
+      security_token: "{{ security_token | default(omit) }}"
+      region: "{{ aws_region }}"
+  block:
+
+    # ============================================================
+
+    - name: 'Create a simple bucket with public access block configuration'
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: present
+        public_access:
+          block_public_acls: true
+          block_public_policy: true
+          ignore_public_acls: true
+          restrict_public_buckets: true
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+          - output.public_access_block
+          - output.public_access_block.BlockPublicAcls
+          - output.public_access_block.BlockPublicPolicy
+          - output.public_access_block.IgnorePublicAcls
+          - output.public_access_block.RestrictPublicBuckets
+
+    - name: 'Re-configure public access block configuration'
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: present
+        public_access:
+          block_public_acls: true
+          block_public_policy: false
+          ignore_public_acls: true
+          restrict_public_buckets: false
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+          - output.public_access_block
+          - output.public_access_block.BlockPublicAcls
+          - not output.public_access_block.BlockPublicPolicy
+          - output.public_access_block.IgnorePublicAcls
+          - not output.public_access_block.RestrictPublicBuckets
+
+    - name: 'Re-configure public access block configuration (idempotency)'
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: present
+        public_access:
+          block_public_acls: true
+          block_public_policy: false
+          ignore_public_acls: true
+          restrict_public_buckets: false
+      register: output
+
+    - assert:
+        that:
+          - output is not changed
+          - output.public_access_block
+          - output.public_access_block.BlockPublicAcls
+          - not output.public_access_block.BlockPublicPolicy
+          - output.public_access_block.IgnorePublicAcls
+          - not output.public_access_block.RestrictPublicBuckets
+
+    - name: 'Delete public access block configuration'
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: present
+        delete_public_access: true
+      register: output
+
+    - assert:
+        that:
+          - output is changed
+          - not output.public_access_block|bool
+
+    - name: 'Delete public access block configuration (idempotency)'
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: present
+        delete_public_access: true
+      register: output
+
+    - assert:
+        that:
+          - output is not changed
+          - not output.public_access_block|bool
+
+    # ============================================================
+
+    - name: Delete testing s3 bucket
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: absent
+      register: output
+
+    - assert:
+        that:
+          - output.changed
+
+  # ============================================================
+  always:
+    - name: Ensure all buckets are deleted
+      s3_bucket:
+        name: '{{ bucket_name }}'
+        state: absent
+      ignore_errors: yes
diff --git a/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/simple.yml b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/simple.yml
--- a/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/simple.yml
+++ b/tests/integration/targets/s3_bucket/roles/s3_bucket/tasks/simple.yml
@@ -16,6 +16,7 @@
           - output is changed
           - output.name == '{{ bucket_name }}'
           - not output.requester_pays
+          - output.public_access is undefined
 
     # ============================================================
     - name: 'Try to update the simple bucket with the same values'
@@ -44,7 +45,7 @@
           - output is changed
 
     # ============================================================
-    - name: 'Re-delete the simple s3_bucket (idemoptency)'
+    - name: 'Re-delete the simple s3_bucket (idempotency)'
       s3_bucket:
         name: '{{ bucket_name }}'
         state: absent