-
Notifications
You must be signed in to change notification settings - Fork 397
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
[PR #925/87848dcc backport][stable-3] New module for creating Cloudfront header policies… This is a backport of PR #925 as merged into main (87848dc). .. used for response headers SUMMARY New Cloudfront module for CF response headers policies, see https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-response-headers.html This is still a relavily new feature, see https://aws.amazon.com/de/blogs/networking-and-content-delivery/amazon-cloudfront-introduces-response-headers-policies/ ISSUE TYPE New Module Pull Request COMPONENT NAME cloudfront_response_headers_policy.py
- Loading branch information
1 parent
ff46936
commit 4d5eead
Showing
4 changed files
with
389 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,291 @@ | ||
#!/usr/bin/python | ||
# Copyright (c) 2017 Ansible Project | ||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) | ||
|
||
from __future__ import (absolute_import, division, print_function) | ||
__metaclass__ = type | ||
|
||
|
||
DOCUMENTATION = ''' | ||
--- | ||
version_added: 3.2.0 | ||
module: cloudfront_response_headers_policy | ||
short_description: Create, update and delete response headers policies to be used in a Cloudfront distribution | ||
description: | ||
- Create, update and delete response headers policies to be used in a Cloudfront distribution for inserting custom headers | ||
- See docs at U(https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cloudfront.html#CloudFront.Client.create_response_headers_policy) | ||
author: Stefan Horning (@stefanhorning) | ||
extends_documentation_fragment: | ||
- amazon.aws.aws | ||
- amazon.aws.ec2 | ||
options: | ||
state: | ||
description: Decides if the named policy should be absent or present | ||
choices: | ||
- present | ||
- absent | ||
default: present | ||
type: str | ||
name: | ||
description: Name of the policy | ||
required: true | ||
type: str | ||
comment: | ||
description: Description of the policy | ||
required: false | ||
type: str | ||
cors_config: | ||
description: CORS header config block | ||
required: false | ||
default: {} | ||
type: dict | ||
security_headers_config: | ||
description: Security headers config block. For headers suchs as XSS-Protection, Content-Security-Policy or Strict-Transport-Security | ||
required: false | ||
default: {} | ||
type: dict | ||
custom_headers_config: | ||
description: Custom headers config block. Define your own list of headers and values as a list | ||
required: false | ||
default: {} | ||
type: dict | ||
''' | ||
|
||
EXAMPLES = ''' | ||
- name: Creationg a Cloudfront header policy using all predefined header features and a custom header for demonstration | ||
community.aws.cloudfront_response_headers_policy: | ||
name: my-header-policy | ||
comment: My header policy for all the headers | ||
cors_config: | ||
access_control_allow_origins: | ||
items: | ||
- 'https://foo.com/bar' | ||
- 'https://bar.com/foo' | ||
access_control_allow_headers: | ||
items: | ||
- 'X-Session-Id' | ||
access_control_allow_methods: | ||
items: | ||
- GET | ||
- OPTIONS | ||
- HEAD | ||
access_control_allow_credentials: true | ||
access_control_expose_headers: | ||
items: | ||
- 'X-Session-Id' | ||
access_control_max_age_sec: 1800 | ||
origin_override: true | ||
security_headers_config: | ||
xss_protection: | ||
protection: true | ||
report_uri: 'https://my.report-uri.com/foo/bar' | ||
override: true | ||
frame_options: | ||
frame_option: 'SAMEORIGIN' | ||
override: true | ||
referrer_policy: | ||
referrer_policy: 'same-origin' | ||
override: true | ||
content_security_policy: | ||
content_security_policy: "frame-ancestors 'none'; report-uri https://my.report-uri.com/r/d/csp/enforce;" | ||
override: true | ||
content_type_options: | ||
override: true | ||
strict_transport_security: | ||
include_subdomains: true | ||
preload: true | ||
access_control_max_age_sec: 63072000 | ||
override: true | ||
custom_headers_config: | ||
items: | ||
- { header: 'X-Test-Header', value: 'Foo', override: true } | ||
state: present | ||
- name: Delete header policy | ||
community.aws.cloudfront_response_headers_policy: | ||
name: my-header-policy | ||
state: absent | ||
''' | ||
|
||
RETURN = ''' | ||
response_headers_policy: | ||
description: The policy's information | ||
returned: success | ||
type: complex | ||
contains: | ||
id: | ||
description: ID of the policy | ||
returned: always | ||
type: str | ||
sample: '10a45b52-630e-4b7c-77c6-205f06df0462' | ||
last_modified_time: | ||
description: Timestamp of last modification of policy | ||
returned: always | ||
type: str | ||
sample: '2022-02-04T13:23:27.304000+00:00' | ||
response_headers_policy_config: | ||
description: The response headers config dict containing all the headers configured | ||
returned: always | ||
type: complex | ||
contains: | ||
name: | ||
description: Name of the policy | ||
type: str | ||
returned: always | ||
sample: my-header-policy | ||
''' | ||
|
||
try: | ||
from botocore.exceptions import ClientError, ParamValidationError, BotoCoreError | ||
except ImportError: | ||
pass # caught by imported AnsibleAWSModule | ||
|
||
from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, snake_dict_to_camel_dict | ||
from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule | ||
import datetime | ||
|
||
|
||
class CloudfrontResponseHeadersPolicyService(object): | ||
|
||
def __init__(self, module): | ||
self.module = module | ||
self.client = module.client('cloudfront') | ||
self.check_mode = module.check_mode | ||
|
||
def find_response_headers_policy(self, name): | ||
try: | ||
policies = self.client.list_response_headers_policies()['ResponseHeadersPolicyList']['Items'] | ||
|
||
for policy in policies: | ||
if policy['ResponseHeadersPolicy']['ResponseHeadersPolicyConfig']['Name'] == name: | ||
policy_id = policy['ResponseHeadersPolicy']['Id'] | ||
# as the list_ request does not contain the Etag (which we need), we need to do another get_ request here | ||
matching_policy = self.client.get_response_headers_policy(Id=policy['ResponseHeadersPolicy']['Id']) | ||
break | ||
else: | ||
matching_policy = None | ||
|
||
return matching_policy | ||
except (ParamValidationError, ClientError, BotoCoreError) as e: | ||
self.module.fail_json_aws(e, msg="Error fetching policy information") | ||
|
||
def create_response_header_policy(self, name, comment, cors_config, security_headers_config, custom_headers_config): | ||
cors_config = snake_dict_to_camel_dict(cors_config, capitalize_first=True) | ||
security_headers_config = snake_dict_to_camel_dict(security_headers_config, capitalize_first=True) | ||
|
||
# Little helper for turning xss_protection into XSSProtection and not into XssProtection | ||
if 'XssProtection' in security_headers_config: | ||
security_headers_config['XSSProtection'] = security_headers_config.pop('XssProtection') | ||
|
||
custom_headers_config = snake_dict_to_camel_dict(custom_headers_config, capitalize_first=True) | ||
|
||
config = { | ||
'Name': name, | ||
'Comment': comment, | ||
'CorsConfig': self.insert_quantities(cors_config), | ||
'SecurityHeadersConfig': security_headers_config, | ||
'CustomHeadersConfig': self.insert_quantities(custom_headers_config) | ||
} | ||
|
||
config = {k: v for k, v in config.items() if v} | ||
|
||
matching_policy = self.find_response_headers_policy(name) | ||
|
||
changed = False | ||
|
||
if self.check_mode: | ||
self.module.exit_json(changed=True, response_headers_policy=camel_dict_to_snake_dict(config)) | ||
|
||
if matching_policy is None: | ||
try: | ||
result = self.client.create_response_headers_policy(ResponseHeadersPolicyConfig=config) | ||
changed = True | ||
except (ParamValidationError, ClientError, BotoCoreError) as e: | ||
self.module.fail_json_aws(e, msg="Error creating policy") | ||
else: | ||
policy_id = matching_policy['ResponseHeadersPolicy']['Id'] | ||
etag = matching_policy['ETag'] | ||
try: | ||
result = self.client.update_response_headers_policy(Id=policy_id, IfMatch=etag, ResponseHeadersPolicyConfig=config) | ||
|
||
changed_time = result['ResponseHeadersPolicy']['LastModifiedTime'] | ||
seconds = 3 # threshhold for returned timestamp age | ||
seconds_ago = (datetime.datetime.now(changed_time.tzinfo) - datetime.timedelta(0, seconds)) | ||
|
||
# consider change made by this execution of the module if returned timestamp was very recent | ||
if changed_time > seconds_ago: | ||
changed = True | ||
except (ParamValidationError, ClientError, BotoCoreError) as e: | ||
self.module.fail_json_aws(e, msg="Updating creating policy") | ||
|
||
self.module.exit_json(changed=changed, **camel_dict_to_snake_dict(result)) | ||
|
||
def delete_response_header_policy(self, name): | ||
matching_policy = self.find_response_headers_policy(name) | ||
|
||
if matching_policy is None: | ||
self.module.exit_json(msg="Didn't find a matching policy by that name, not deleting") | ||
else: | ||
policy_id = matching_policy['ResponseHeadersPolicy']['Id'] | ||
etag = matching_policy['ETag'] | ||
if self.check_mode: | ||
result = {} | ||
else: | ||
try: | ||
result = self.client.delete_response_headers_policy(Id=policy_id, IfMatch=etag) | ||
except (ParamValidationError, ClientError, BotoCoreError) as e: | ||
self.module.fail_json_aws(e, msg="Error deleting policy") | ||
|
||
self.module.exit_json(changed=True, **camel_dict_to_snake_dict(result)) | ||
|
||
# Inserts a Quantity field into dicts with a list ('Items') | ||
@staticmethod | ||
def insert_quantities(dict_with_items): | ||
# Items on top level case | ||
if 'Items' in dict_with_items and isinstance(dict_with_items['Items'], list): | ||
dict_with_items['Quantity'] = len(dict_with_items['Items']) | ||
|
||
# Items on second level case | ||
for k, v in dict_with_items.items(): | ||
if isinstance(v, dict) and 'Items' in v: | ||
v['Quantity'] = len(v['Items']) | ||
|
||
return dict_with_items | ||
|
||
|
||
def main(): | ||
argument_spec = dict( | ||
name=dict(required=True, type='str'), | ||
comment=dict(type='str'), | ||
cors_config=dict(type='dict', default=dict()), | ||
security_headers_config=dict(type='dict', default=dict()), | ||
custom_headers_config=dict(type='dict', default=dict()), | ||
state=dict(choices=['present', 'absent'], type='str', default='present'), | ||
) | ||
|
||
module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True) | ||
|
||
name = module.params.get('name') | ||
comment = module.params.get('comment', '') | ||
cors_config = module.params.get('cors_config') | ||
security_headers_config = module.params.get('security_headers_config') | ||
custom_headers_config = module.params.get('custom_headers_config') | ||
state = module.params.get('state') | ||
|
||
service = CloudfrontResponseHeadersPolicyService(module) | ||
|
||
if state == 'absent': | ||
service.delete_response_header_policy(name) | ||
else: | ||
service.create_response_header_policy(name, comment, cors_config, security_headers_config, custom_headers_config) | ||
|
||
|
||
if __name__ == '__main__': | ||
main() |
1 change: 1 addition & 0 deletions
1
tests/integration/targets/cloudfront_reponse_headers_policy/aliases
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
cloud/aws |
1 change: 1 addition & 0 deletions
1
tests/integration/targets/cloudfront_reponse_headers_policy/meta/main.yml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
dependencies: [] |
96 changes: 96 additions & 0 deletions
96
tests/integration/targets/cloudfront_reponse_headers_policy/task/main.yml
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
--- | ||
|
||
- name: Integration testing for the cloudfront_response_headers_policy module | ||
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 header policy | ||
cloudfront_response_headers_policy: | ||
name: "{{ resource_prefix }}-my-header-policy" | ||
comment: Created by Ansible test | ||
security_headers_config: | ||
content_type_options: | ||
override: true | ||
state: present | ||
register: create_result | ||
|
||
- name: Assert creation withouth errors and return values | ||
assert: | ||
that: | ||
- create_result is changed | ||
- create_result is not failed | ||
- create_result.response_headers_policy.response_headers_policy_config.name == "{{ resource_prefix }}-my-header-policy" | ||
|
||
- name: Rerun same task to ensure idempotence | ||
cloudfront_response_headers_policy: | ||
name: "{{ resource_prefix }}-my-header-policy" | ||
comment: Created by Ansible test | ||
security_headers_config: | ||
content_type_options: | ||
override: true | ||
state: present | ||
register: rerun_result | ||
|
||
- name: Assert no change and no errors | ||
assert: | ||
that: | ||
- rerun_result is not changed | ||
- rerun_result is not failed | ||
|
||
- name: Update existing policy with more header configs | ||
cloudfront_response_headers_policy: | ||
name: "{{ resource_prefix }}-my-header-policy" | ||
comment: Created by Ansible test | ||
cors_config: | ||
access_control_allow_origins: | ||
items: | ||
- 'https://foo.com/bar' | ||
- 'https://bar.com/foo' | ||
access_control_allow_methods: | ||
items: | ||
- OPTIONS | ||
- HEAD | ||
- GET | ||
access_control_max_age_sec: 1800 | ||
origin_override: true | ||
security_headers_config: | ||
content_type_options: | ||
override: true | ||
custom_headers_config: | ||
items: | ||
- { header: 'X-Test-Header', value: 'Foo', override: true } | ||
state: present | ||
register: update_result | ||
|
||
- name: Assert update and updated return values | ||
assert: | ||
that: | ||
- update_result is changed | ||
- update_result.response_headers_policy.response_headers_policy_config.cors_config.access_control_max_age_sec == 1800 | ||
|
||
- name: Ensure policy is deleted | ||
cloudfront_response_headers_policy: | ||
name: "{{ resource_prefix }}-my-header-policy" | ||
comment: Created by Ansible test | ||
state: absent | ||
register: delete_result | ||
|
||
- name: Assert deletion without errors | ||
assert: | ||
that: | ||
- delete_result is changed | ||
- delete_result is not failed | ||
- update_result.response_headers_policy is undefined | ||
|
||
always: | ||
|
||
- name: Ensure policy is deleted | ||
cloudfront_response_headers_policy: | ||
name: "{{ resource_prefix }}-my-header-policy" | ||
state: absent | ||
ignore_errors: true |