Repository: cloud-custodian/cloud-custodian

Issue:
aws.app-elb resource needs a modify attributes action so deletion protection and other settings can be modified
**Is your feature request related to a problem? Please describe.**
As an AWS best practice I would like to ensure Deletion protection is enabled for all my production aws.app-elb resources but there is not currently a way to enable that in c7n.

**Describe the solution you'd like**
app-elb resource needs a modify-attributes action so we can enable the deletion protection automatically. Right now we can only notify

**Describe alternatives you've considered**
I can't find an alternative automated method without going to a custom script



Discussion:

Resolving patch:
diff --git a/c7n/resources/appelb.py b/c7n/resources/appelb.py
--- a/c7n/resources/appelb.py
+++ b/c7n/resources/appelb.py
@@ -485,6 +485,73 @@ def process_alb(self, client, alb):
                 alb['LoadBalancerArn'], e)
 
 
+@AppELB.action_registry.register('modify-attributes')
+class AppELBModifyAttributes(BaseAction):
+    """Modify load balancer attributes.
+
+    :example:
+
+    .. code-block:: yaml
+
+            policies:
+              - name: turn-on-elb-deletion-protection
+                resource: app-elb
+                filters:
+                  - type: attributes
+                    key: "deletion_protection.enabled"
+                    value: false
+                actions:
+                  - type: modify-attributes
+                    attributes:
+                      "deletion_protection.enabled": "true"
+                      "idle_timeout.timeout_seconds": 120
+    """
+    schema = {
+        'type': 'object',
+        'additionalProperties': False,
+        'properties': {
+            'type': {
+                'enum': ['modify-attributes']},
+            'attributes': {
+                'type': 'object',
+                'additionalProperties': False,
+                'properties': {
+                    'access_logs.s3.enabled': {
+                        'enum': ['true', 'false', True, False]},
+                    'access_logs.s3.bucket': {'type': 'string'},
+                    'access_logs.s3.prefix': {'type': 'string'},
+                    'deletion_protection.enabled': {
+                        'enum': ['true', 'false', True, False]},
+                    'idle_timeout.timeout_seconds': {'type': 'number'},
+                    'routing.http.desync_mitigation_mode': {
+                        'enum': ['monitor', 'defensive', 'strictest']},
+                    'routing.http.drop_invalid_header_fields.enabled': {
+                        'enum': ['true', 'false', True, False]},
+                    'routing.http2.enabled': {
+                        'enum': ['true', 'false', True, False]},
+                    'load_balancing.cross_zone.enabled': {
+                        'enum': ['true', 'false', True, False]},
+                },
+            },
+        },
+    }
+    permissions = ("elasticloadbalancing:ModifyLoadBalancerAttributes",)
+
+    def process(self, resources):
+        client = local_session(self.manager.session_factory).client('elbv2')
+        for appelb in resources:
+            self.manager.retry(
+                client.modify_load_balancer_attributes,
+                LoadBalancerArn=appelb['LoadBalancerArn'],
+                Attributes=[
+                    {'Key': key, 'Value': serialize_attribute_value(value)}
+                    for (key, value) in self.data['attributes'].items()
+                ],
+                ignore_err_codes=('LoadBalancerNotFoundException',),
+            )
+        return resources
+
+
 class AppELBListenerFilterBase:
     """ Mixin base class for filters that query LB listeners.
     """
@@ -512,6 +579,16 @@ def parse_attribute_value(v):
     return v
 
 
+def serialize_attribute_value(v):
+    if v is True:
+        return 'true'
+    elif v is False:
+        return 'false'
+    elif isinstance(v, int):
+        return str(v)
+    return v
+
+
 class AppELBAttributeFilterBase:
     """ Mixin base class for filters that query LB attributes.
     """


Test patch:
diff --git a/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_1.json b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_1.json
@@ -0,0 +1,40 @@
+{
+    "status_code": 200,
+    "data": {
+        "Attributes": [
+            {
+                "Key": "access_logs.s3.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "access_logs.s3.bucket",
+                "Value": ""
+            },
+            {
+                "Key": "access_logs.s3.prefix",
+                "Value": ""
+            },
+            {
+                "Key": "idle_timeout.timeout_seconds",
+                "Value": "60"
+            },
+            {
+                "Key": "deletion_protection.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "routing.http2.enabled",
+                "Value": "true"
+            },
+            {
+                "Key": "routing.http.drop_invalid_header_fields.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "routing.http.desync_mitigation_mode",
+                "Value": "defensive"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_2.json b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_2.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancerAttributes_2.json
@@ -0,0 +1,40 @@
+{
+    "status_code": 200,
+    "data": {
+        "Attributes": [
+            {
+                "Key": "access_logs.s3.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "access_logs.s3.bucket",
+                "Value": ""
+            },
+            {
+                "Key": "access_logs.s3.prefix",
+                "Value": ""
+            },
+            {
+                "Key": "idle_timeout.timeout_seconds",
+                "Value": "60"
+            },
+            {
+                "Key": "deletion_protection.enabled",
+                "Value": "true"
+            },
+            {
+                "Key": "routing.http2.enabled",
+                "Value": "true"
+            },
+            {
+                "Key": "routing.http.drop_invalid_header_fields.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "routing.http.desync_mitigation_mode",
+                "Value": "defensive"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancers_1.json b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancers_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeLoadBalancers_1.json
@@ -0,0 +1,46 @@
+{
+    "status_code": 200,
+    "data": {
+        "LoadBalancers": [
+            {
+                "LoadBalancerArn": "arn:aws:elasticloadbalancing:us-east-1:644160558196:loadbalancer/app/elb1/8b7cb6efe5a9affa",
+                "DNSName": "internal-elb1-906612786.us-east-1.elb.amazonaws.com",
+                "CanonicalHostedZoneId": "Z35SXDOTRQ7X7K",
+                "CreatedTime": {
+                    "__class__": "datetime",
+                    "year": 2020,
+                    "month": 10,
+                    "day": 15,
+                    "hour": 18,
+                    "minute": 13,
+                    "second": 14,
+                    "microsecond": 420000
+                },
+                "LoadBalancerName": "elb1",
+                "Scheme": "internal",
+                "VpcId": "vpc-0fb8ef65e0f8b24c6",
+                "State": {
+                    "Code": "active"
+                },
+                "Type": "application",
+                "AvailabilityZones": [
+                    {
+                        "ZoneName": "us-east-1d",
+                        "SubnetId": "subnet-08b3756c9cb23e8ee",
+                        "LoadBalancerAddresses": []
+                    },
+                    {
+                        "ZoneName": "us-east-1f",
+                        "SubnetId": "subnet-0a44119baded84958",
+                        "LoadBalancerAddresses": []
+                    }
+                ],
+                "SecurityGroups": [
+                    "sg-0384f7fdcd3010513"
+                ],
+                "IpAddressType": "ipv4"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeTags_1.json b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeTags_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.DescribeTags_1.json
@@ -0,0 +1,12 @@
+{
+    "status_code": 200,
+    "data": {
+        "TagDescriptions": [
+            {
+                "ResourceArn": "arn:aws:elasticloadbalancing:us-east-1:644160558196:loadbalancer/app/elb1/8b7cb6efe5a9affa",
+                "Tags": []
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.ModifyLoadBalancerAttributes_1.json b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.ModifyLoadBalancerAttributes_1.json
new file mode 100644
--- /dev/null
+++ b/tests/data/placebo/test_appelb_modify_attributes/elasticloadbalancing.ModifyLoadBalancerAttributes_1.json
@@ -0,0 +1,40 @@
+{
+    "status_code": 200,
+    "data": {
+        "Attributes": [
+            {
+                "Key": "deletion_protection.enabled",
+                "Value": "true"
+            },
+            {
+                "Key": "access_logs.s3.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "access_logs.s3.bucket",
+                "Value": ""
+            },
+            {
+                "Key": "access_logs.s3.prefix",
+                "Value": ""
+            },
+            {
+                "Key": "idle_timeout.timeout_seconds",
+                "Value": "60"
+            },
+            {
+                "Key": "routing.http2.enabled",
+                "Value": "true"
+            },
+            {
+                "Key": "routing.http.drop_invalid_header_fields.enabled",
+                "Value": "false"
+            },
+            {
+                "Key": "routing.http.desync_mitigation_mode",
+                "Value": "defensive"
+            }
+        ],
+        "ResponseMetadata": {}
+    }
+}
\ No newline at end of file
diff --git a/tests/test_appelb.py b/tests/test_appelb.py
--- a/tests/test_appelb.py
+++ b/tests/test_appelb.py
@@ -5,7 +5,14 @@
 from .common import BaseTest, event_data
 from c7n.exceptions import PolicyValidationError
 from c7n.executor import MainThreadExecutor
-from c7n.resources.appelb import AppELB, AppELBTargetGroup
+from c7n.resources.appelb import AppELB, AppELBTargetGroup, serialize_attribute_value
+
+
+def test_serialize():
+    assert serialize_attribute_value(True) == 'true'
+    assert serialize_attribute_value(False) == 'false'
+    assert serialize_attribute_value(60) == '60'
+    assert serialize_attribute_value('abc') == 'abc'
 
 
 class AppELBTest(BaseTest):
@@ -364,6 +371,40 @@ def test_appelb_delete_force(self):
         resources = p.run()
         self.assertEqual(len(resources), 1)
 
+    def test_appelb_modify_attributes(self):
+        session_factory = self.replay_flight_data(
+            "test_appelb_modify_attributes")
+        client = session_factory().client("elbv2")
+        p = self.load_policy(
+            {
+                "name": "appelb-enable-deletion-protection",
+                "resource": "app-elb",
+                "filters": [
+                    {
+                        "type": "attributes",
+                        "key": "deletion_protection.enabled",
+                        "value": False,
+                    },
+                ],
+                "actions": [
+                    {
+                        "type": "modify-attributes",
+                        "attributes": {
+                            "deletion_protection.enabled": "true",
+                        },
+                    },
+                ],
+            },
+            session_factory=session_factory,
+        )
+        resources = p.run()
+        self.assertEqual(len(resources), 1)
+        arn = resources[0]["LoadBalancerArn"]
+        attrs = client.describe_load_balancer_attributes(
+            LoadBalancerArn=arn)["Attributes"]
+        attrs = {obj['Key']: obj['Value'] for obj in attrs}
+        assert attrs['deletion_protection.enabled'] == 'true'
+
     def test_appelb_waf_any(self):
         factory = self.replay_flight_data("test_appelb_waf")
         p = self.load_policy({