diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d7e818bb1..879597a071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,7 @@ See [Porting Guide](http://docs.ansible.com/ansible/devel/porting_guides.html) f * aws_ssm_parameter_store * aws_waf_condition * aws_waf_rule + * aws_waf_web_acl * cloudfront_distribution * cloudfront_invalidation * cloudfront_origin_access_identity diff --git a/lib/ansible/modules/cloud/amazon/aws_waf_web_acl.py b/lib/ansible/modules/cloud/amazon/aws_waf_web_acl.py new file mode 100644 index 0000000000..0e705d816f --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/aws_waf_web_acl.py @@ -0,0 +1,290 @@ +#!/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) + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +module: aws_waf_web_acl +short_description: create and delete WAF Web ACLs +description: + - Read the AWS documentation for WAF + U(https://aws.amazon.com/documentation/waf/) +version_added: "2.5" + +author: + - Mike Mochan (@mmochan) + - Will Thames (@willthames) +extends_documentation_fragment: + - aws + - ec2 +options: + name: + description: Name of the Web Application Firewall ACL to manage + required: yes + default_action: + description: The action that you want AWS WAF to take when a request doesn't + match the criteria specified in any of the Rule objects that are associated with the WebACL + choices: + - block + - allow + - count + state: + description: whether the Web ACL should be present or absent + choices: + - present + - absent + default: present + metric_name: + description: + - A friendly name or description for the metrics for this WebACL + - The name can contain only alphanumeric characters (A-Z, a-z, 0-9); the name can't contain whitespace. + - You can't change metric_name after you create the WebACL + - Metric name will default to I(name) with disallowed characters stripped out + rules: + description: + - A list of rules that the Web ACL will enforce. + - Each rule must contain I(name), I(action), I(priority) keys. + - Priorities must be unique, but not necessarily consecutive. Lower numbered priorities are evalauted first. + - The I(type) key can be passed as C(rate_based), it defaults to C(regular) + + purge_rules: + description: Whether to remove rules that aren't passed with C(rules). Defaults to false +''' + +EXAMPLES = ''' + - name: create web ACL + aws_waf_web_acl: + name: my_web_acl + rules: + - name: my_rule + priority: 1 + action: block + default_action: block + purge_rules: yes + state: present + + - name: delete the web acl + aws_waf_web_acl: + name: my_web_acl + state: absent +''' + +RETURN = ''' +web_acl: + description: contents of the Web ACL + returned: always + type: complex + contains: + default_action: + description: Default action taken by the Web ACL if no rules match + returned: always + type: dict + sample: + type: BLOCK + metric_name: + description: Metric name used as an identifier + returned: always + type: string + sample: mywebacl + name: + description: Friendly name of the Web ACL + returned: always + type: string + sample: my web acl + rules: + description: List of rules + returned: always + type: complex + contains: + action: + description: Action taken by the WAF when the rule matches + returned: always + type: complex + sample: + type: ALLOW + priority: + description: priority number of the rule (lower numbers are run first) + returned: always + type: int + sample: 2 + rule_id: + description: Rule ID + returned: always + type: string + sample: a6fc7ab5-287b-479f-8004-7fd0399daf75 + type: + description: Type of rule (either REGULAR or RATE_BASED) + returned: always + type: string + sample: REGULAR + web_acl_id: + description: Unique identifier of Web ACL + returned: always + type: string + sample: 10fff965-4b6b-46e2-9d78-24f6d2e2d21c +''' + +try: + import botocore +except ImportError: + pass # handled by AnsibleAWSModule + +import re + +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.ec2 import boto3_conn, get_aws_connection_info, ec2_argument_spec, camel_dict_to_snake_dict +from ansible.module_utils.aws.waf import list_rules_with_backoff, list_web_acls_with_backoff, get_change_token + + +def get_web_acl_by_name(client, module, name): + acls = [d['WebACLId'] for d in list_web_acls(client, module) if d['Name'] == name] + if acls: + return acls[0] + else: + return acls + + +def create_rule_lookup(client, module): + try: + rules = list_rules_with_backoff(client) + return dict((rule['Name'], rule) for rule in rules) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not list rules') + + +def get_web_acl(client, module, web_acl_id): + try: + return client.get_web_acl(WebACLId=web_acl_id)['WebACL'] + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not get Web ACL with id %s' % web_acl_id) + + +def list_web_acls(client, module,): + try: + return list_web_acls_with_backoff(client) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not get Web ACLs') + + +def find_and_update_web_acl(client, module, web_acl_id): + acl = get_web_acl(client, module, web_acl_id) + rule_lookup = create_rule_lookup(client, module) + existing_rules = acl['Rules'] + desired_rules = [{'RuleId': rule_lookup[rule['name']]['RuleId'], + 'Priority': rule['priority'], + 'Action': {'Type': rule['action'].upper()}, + 'Type': rule.get('type', 'regular').upper()} + for rule in module.params['rules']] + missing = [rule for rule in desired_rules if rule not in existing_rules] + extras = [] + if module.params['purge_rules']: + extras = [rule for rule in existing_rules if rule not in desired_rules] + + insertions = [format_for_update(rule, 'INSERT') for rule in missing] + deletions = [format_for_update(rule, 'DELETE') for rule in extras] + changed = bool(insertions + deletions) + if changed: + try: + client.update_web_acl( + WebACLId=acl['WebACLId'], + ChangeToken=get_change_token(client, module), + Updates=insertions + deletions, + DefaultAction=acl['DefaultAction'] + ) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not update Web ACL') + acl = get_web_acl(client, module, web_acl_id) + return changed, acl + + +def format_for_update(rule, action): + return dict( + Action=action, + ActivatedRule=dict( + Priority=rule['Priority'], + RuleId=rule['RuleId'], + Action=dict( + Type=rule['Action']['Type'] + ) + ) + ) + + +def remove_rules_from_web_acl(client, module, web_acl_id): + acl = get_web_acl(client, module, web_acl_id) + deletions = [format_for_update(rule, 'DELETE') for rule in acl['Rules']] + try: + client.update_web_acl(WebACLId=acl['WebACLId'], ChangeToken=get_change_token(client, module), + Updates=deletions, DefaultAction=acl['DefaultAction']) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not remove rule') + + +def ensure_web_acl_present(client, module): + changed = False + result = None + name = module.params['name'] + web_acl_id = get_web_acl_by_name(client, module, name) + if web_acl_id: + (changed, result) = find_and_update_web_acl(client, module, web_acl_id) + else: + metric_name = module.params['metric_name'] + if not metric_name: + metric_name = re.sub(r'[^A-Za-z0-9]', '', module.params['name']) + default_action = module.params['default_action'].upper() + try: + new_web_acl = client.create_web_acl(Name=name, MetricName=metric_name, + DefaultAction={'Type': default_action}, + ChangeToken=get_change_token(client, module)) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not create Web ACL') + (changed, result) = find_and_update_web_acl(client, module, new_web_acl['WebACL']['WebACLId']) + return changed, result + + +def ensure_web_acl_absent(client, module): + web_acl_id = get_web_acl_by_name(client, module, module.params['name']) + if web_acl_id: + web_acl = get_web_acl(client, module, web_acl_id) + if web_acl['Rules']: + remove_rules_from_web_acl(client, module, web_acl_id) + try: + client.delete_web_acl(WebACLId=web_acl_id, ChangeToken=get_change_token(client, module)) + return True, {} + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + module.fail_json_aws(e, msg='Could not delete Web ACL') + return False, {} + + +def main(): + argument_spec = ec2_argument_spec() + argument_spec.update( + dict( + name=dict(required=True), + default_action=dict(choices=['block', 'allow', 'count']), + metric_name=dict(), + state=dict(default='present', choices=['present', 'absent']), + rules=dict(type='list'), + purge_rules=dict(type='bool', default=False) + ), + ) + module = AnsibleAWSModule(argument_spec=argument_spec, + required_if=[['state', 'present', ['default_action', 'rules']]]) + state = module.params.get('state') + + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True) + client = boto3_conn(module, conn_type='client', resource='waf', region=region, endpoint=ec2_url, **aws_connect_kwargs) + + if state == 'present': + (changed, results) = ensure_web_acl_present(client, module) + else: + (changed, results) = ensure_web_acl_absent(client, module) + + module.exit_json(changed=changed, web_acl=camel_dict_to_snake_dict(results)) + + +if __name__ == '__main__': + main() diff --git a/test/integration/targets/aws_waf_web_acl/tasks/main.yml b/test/integration/targets/aws_waf_web_acl/tasks/main.yml index 1e19ae4747..faf5cc88f1 100644 --- a/test/integration/targets/aws_waf_web_acl/tasks/main.yml +++ b/test/integration/targets/aws_waf_web_acl/tasks/main.yml @@ -345,10 +345,149 @@ - remove_in_use_condition.failed - "'Condition {{ resource_prefix }}_size_condition is in use' in remove_in_use_condition.msg" + ################################################## + # aws_waf_web_acl tests + ################################################## + + - name: create web ACL + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + rules: + - name: "{{ resource_prefix }}_rule" + priority: 1 + action: block + default_action: block + purge_rules: yes + state: present + <<: *aws_connection_info + register: create_web_acl + + - name: recreate web acl + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + rules: + - name: "{{ resource_prefix }}_rule" + priority: 1 + action: block + default_action: block + state: present + <<: *aws_connection_info + register: recreate_web_acl + + - name: check web acl was not changed + assert: + that: + - not recreate_web_acl.changed + - recreate_web_acl.web_acl.rules|length == 1 + + - name: create a second WAF rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule_2" + conditions: + - name: "{{ resource_prefix }}_ip_condition" + type: ip + negated: yes + - name: "{{ resource_prefix }}_sql_condition" + type: sql + negated: no + - name: "{{ resource_prefix }}_xss_condition" + type: xss + negated: no + <<: *aws_connection_info + + - name: add a new rule to the web acl + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + rules: + - name: "{{ resource_prefix }}_rule_2" + priority: 2 + action: allow + default_action: block + state: present + <<: *aws_connection_info + register: web_acl_add_rule + + - name: check that rule was added to the web acl + assert: + that: + - web_acl_add_rule.changed + - web_acl_add_rule.web_acl.rules|length == 2 + + - name: use purge rules to remove the first rule + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + rules: + - name: "{{ resource_prefix }}_rule_2" + priority: 2 + action: allow + purge_rules: yes + default_action: block + state: present + <<: *aws_connection_info + register: web_acl_add_rule + + - name: check that rule was removed from the web acl + assert: + that: + - web_acl_add_rule.changed + - web_acl_add_rule.web_acl.rules|length == 1 + + - name: swap two rules of same priority + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + rules: + - name: "{{ resource_prefix }}_rule" + priority: 2 + action: allow + purge_rules: yes + default_action: block + state: present + <<: *aws_connection_info + register: web_acl_swap_rule + + - name: attempt to delete the inuse first rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + state: absent + <<: *aws_connection_info + ignore_errors: yes + register: remove_inuse_rule + + - name: check that removing in-use rule fails + assert: + that: + - remove_inuse_rule.failed + + - name: delete the web acl + aws_waf_web_acl: + name: "{{ resource_prefix }}_web_acl" + state: absent + <<: *aws_connection_info + register: delete_web_acl + + - name: check that web acl was deleted + assert: + that: + - delete_web_acl.changed + - not delete_web_acl.web_acl + + - name: delete the no longer in use first rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule" + state: absent + <<: *aws_connection_info + always: - debug: msg: "****** TEARDOWN STARTS HERE ******" + - name: remove second WAF rule + aws_waf_rule: + name: "{{ resource_prefix }}_rule_2" + state: absent + <<: *aws_connection_info + ignore_errors: yes + - name: remove WAF rule aws_waf_rule: name: "{{ resource_prefix }}_rule"