Repository: serverless/serverless (JavaScript)
Date: 2019-06-23 21:45:59+00:00
Task type: Feature

Issue:
Add support for all listener rule conditions on ALB events
# This is a Feature Proposal

## Feature
Add support for all listener rule conditions for ALB events.

## Description
Listener rules can have the following conditions:
- http-header
- http-request-method
- host-header
- path-pattern
- query-string
- source-ip

At first, only `path-pattern` and `host-header` conditions were supported by CloudFormation but now, they are all supported (see [AWS documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-elasticloadbalancingv2-listenerrule-conditions.html)).

Serverless supports only  `path-pattern` and `host-header` conditions. I would like to add the remaining conditions in this way: 

```yaml
# serverless.yaml
functions:
    hello:
        handler: handler.hello
        events:
          - alb:
              priority: 1
              listenerArn: <some listener arn>
              conditions:
                path: '/hello'
                method: POST
                header:
                  name: 'api-key'
                  values: 
                    - 'foo'
                    - 'bar'
                query:
                   foo: 'bar'
                   bar: 'baz'
                ip:
                  - 1.1.1.1/32
```
Note: 
- It would be nice if `ip` and `method` conditions could take either a string or an array of string as value
- This example would not work since listener rule accept up to 5 conditions (multiple values for one condition type such as `query` count as multiple conditions).

I have already [implemented the changes](https://github.com/axel-springer-kugawana/serverless-alb-plugin/commit/b28075b5c608a09d74f08af19ad7594ab0c91294) on my plugin so if we agree on the abstraction, I can start implementing the changes on Serverless right away.

## Related issues
* #6073
* #5572



Issue discussion:
@cbm-egoubely What you've outlined looks very good, we'll be very happy to take PR that supports that!

If you need any help let us know.

Solution patch:
diff --git a/docs/providers/aws/events/alb.md b/docs/providers/aws/events/alb.md
index 72f58bfce7f..a367d17aac2 100644
--- a/docs/providers/aws/events/alb.md
+++ b/docs/providers/aws/events/alb.md
@@ -18,6 +18,20 @@ The Serverless Framework makes it possible to setup the connection between Appli
 
 ## Event definition
 
+```yml
+functions:
+  albEventConsumer:
+    handler: handler.hello
+    events:
+      - alb:
+          listenerArn: arn:aws:elasticloadbalancing:us-east-1:12345:listener/app/my-load-balancer/50dc6c495c0c9188/
+          priority: 1
+          conditions:
+            path: /hello
+```
+
+## Using different conditions
+
 ```yml
 functions:
   albEventConsumer:
@@ -29,4 +43,19 @@ functions:
           conditions:
             host: example.com
             path: /hello
+            method:
+              - POST
+              - PATCH
+            host:
+              - example.com
+              - example2.com
+            header:
+              name: foo
+              values:
+                - bar
+            query:
+              bar: true
+            ip:
+              - fe80:0000:0000:0000:0204:61ff:fe9d:f156/6
+              - 192.168.0.1/0
 ```
diff --git a/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.js b/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.js
index f4e9786f83c..462a5522c1a 100644
--- a/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.js
+++ b/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.js
@@ -11,16 +11,51 @@ module.exports = {
       const Conditions = [
         {
           Field: 'path-pattern',
-          Values: [event.conditions.path],
+          Values: event.conditions.path,
         },
       ];
       if (event.conditions.host) {
         Conditions.push({
           Field: 'host-header',
-          Values: [event.conditions.host],
+          Values: event.conditions.host,
+        });
+      }
+      if (event.conditions.method) {
+        Conditions.push({
+          Field: 'http-request-method',
+          HttpRequestMethodConfig: {
+            Values: event.conditions.method,
+          },
+        });
+      }
+      if (event.conditions.header) {
+        Conditions.push({
+          Field: 'http-header',
+          HttpHeaderConfig: {
+            HttpHeaderName: event.conditions.header.name,
+            Values: event.conditions.header.values,
+          },
+        });
+      }
+      if (event.conditions.query) {
+        Conditions.push({
+          Field: 'query-string',
+          QueryStringConfig: {
+            Values: Object.keys(event.conditions.query).map(key => ({
+              Key: key,
+              Value: event.conditions.query[key],
+            })),
+          },
+        });
+      }
+      if (event.conditions.ip) {
+        Conditions.push({
+          Field: 'source-ip',
+          SourceIpConfig: {
+            Values: event.conditions.ip,
+          },
         });
       }
-
       Object.assign(this.serverless.service.provider.compiledCloudFormationTemplate.Resources, {
         [listenerRuleLogicalId]: {
           Type: 'AWS::ElasticLoadBalancingV2::ListenerRule',
diff --git a/lib/plugins/aws/package/compile/events/alb/lib/validate.js b/lib/plugins/aws/package/compile/events/alb/lib/validate.js
index 9bb821f0aa3..76ecd8ffea4 100644
--- a/lib/plugins/aws/package/compile/events/alb/lib/validate.js
+++ b/lib/plugins/aws/package/compile/events/alb/lib/validate.js
@@ -2,6 +2,10 @@
 
 const _ = require('lodash');
 
+// eslint-disable-next-line max-len
+const CIDR_IPV6_PATTERN = /^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
+const CIDR_IPV4_PATTERN = /^([0-9]{1,3}\.){3}[0-9]{1,3}(\/([0-9]|[1-2][0-9]|3[0-2]))$/;
+
 module.exports = {
   validate() {
     const events = [];
@@ -14,13 +18,26 @@ module.exports = {
               listenerArn: event.alb.listenerArn,
               priority: event.alb.priority,
               conditions: {
-                path: event.alb.conditions.path,
+                // concat usage allows the user to provide value as a string or an array
+                path: _.concat(event.alb.conditions.path),
               },
               // the following is data which is not defined on the event-level
               functionName,
             };
             if (event.alb.conditions.host) {
-              albObj.conditions.host = event.alb.conditions.host;
+              albObj.conditions.host = _.concat(event.alb.conditions.host);
+            }
+            if (event.alb.conditions.method) {
+              albObj.conditions.method = _.concat(event.alb.conditions.method);
+            }
+            if (event.alb.conditions.header) {
+              albObj.conditions.header = this.validateHeaderCondition(event, functionName);
+            }
+            if (event.alb.conditions.query) {
+              albObj.conditions.query = this.validateQueryCondition(event, functionName);
+            }
+            if (event.alb.conditions.ip) {
+              albObj.conditions.ip = this.validateIpCondition(event, functionName);
             }
             events.push(albObj);
           }
@@ -32,4 +49,51 @@ module.exports = {
       events,
     };
   },
+
+  validateHeaderCondition(event, functionName) {
+    const messageTitle = `Invalid ALB event "header" condition in function "${functionName}".`;
+    if (!_.isObject(event.alb.conditions.header)
+      || !event.alb.conditions.header.name
+      || !event.alb.conditions.header.values) {
+      const errorMessage = [
+        messageTitle,
+        ' You must provide an object with "name" and "values" properties.',
+      ].join('');
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+    if (!_.isArray(event.alb.conditions.header.values)) {
+      const errorMessage = [
+        messageTitle,
+        ' Property "values" must be an array.',
+      ].join('');
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+    return event.alb.conditions.header;
+  },
+
+  validateQueryCondition(event, functionName) {
+    if (!_.isObject(event.alb.conditions.query)) {
+      const errorMessage = [
+        `Invalid ALB event "query" condition in function "${functionName}".`,
+        ' You must provide an object.',
+      ].join('');
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+    return event.alb.conditions.query;
+  },
+
+  validateIpCondition(event, functionName) {
+    const cidrBlocks = _.concat(event.alb.conditions.ip);
+    const allValuesAreCidr = cidrBlocks
+      .every(cidr => CIDR_IPV4_PATTERN.test(cidr) || CIDR_IPV6_PATTERN.test(cidr));
+
+    if (!allValuesAreCidr) {
+      const errorMessage = [
+        `Invalid ALB event "ip" condition in function "${functionName}".`,
+        ' You must provide values in a valid IPv4 or IPv6 CIDR format.',
+      ].join('');
+      throw new this.serverless.classes.Error(errorMessage);
+    }
+    return cidrBlocks;
+  },
 };


Test patch:
diff --git a/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.test.js b/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.test.js
index 4ce62477450..d9d4f22b417 100644
--- a/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.test.js
+++ b/lib/plugins/aws/package/compile/events/alb/lib/listenerRules.test.js
@@ -27,8 +27,8 @@ describe('#compileListenerRules()', () => {
             + '50dc6c495c0c9188/f2f7dc8efc522ab2',
           priority: 1,
           conditions: {
-            host: 'example.com',
-            path: '/hello',
+            host: ['example.com'],
+            path: ['/hello'],
           },
         },
         {
@@ -38,7 +38,7 @@ describe('#compileListenerRules()', () => {
             + '50dc6c495c0c9188/f2f7dc8efc522ab2',
           priority: 2,
           conditions: {
-            path: '/world',
+            path: ['/world'],
           },
         },
       ],
diff --git a/lib/plugins/aws/package/compile/events/alb/lib/validate.test.js b/lib/plugins/aws/package/compile/events/alb/lib/validate.test.js
index 889bd19f18b..103831fc158 100644
--- a/lib/plugins/aws/package/compile/events/alb/lib/validate.test.js
+++ b/lib/plugins/aws/package/compile/events/alb/lib/validate.test.js
@@ -30,6 +30,8 @@ describe('#validate()', () => {
               conditions: {
                 host: 'example.com',
                 path: '/hello',
+                method: 'GET',
+                ip: ['192.168.0.1/1', 'fe80:0000:0000:0000:0204:61ff:fe9d:f156/3'],
               },
             },
           },
@@ -45,6 +47,10 @@ describe('#validate()', () => {
               priority: 2,
               conditions: {
                 path: '/world',
+                method: ['POST', 'GET'],
+                query: {
+                  foo: 'bar',
+                },
               },
             },
           },
@@ -62,8 +68,10 @@ describe('#validate()', () => {
           + '50dc6c495c0c9188/f2f7dc8efc522ab2',
         priority: 1,
         conditions: {
-          host: 'example.com',
-          path: '/hello',
+          host: ['example.com'],
+          path: ['/hello'],
+          method: ['GET'],
+          ip: ['192.168.0.1/1', 'fe80:0000:0000:0000:0204:61ff:fe9d:f156/3'],
         },
       },
       {
@@ -73,9 +81,132 @@ describe('#validate()', () => {
           + '50dc6c495c0c9188/f2f7dc8efc522ab2',
         priority: 2,
         conditions: {
-          path: '/world',
+          path: ['/world'],
+          method: ['POST', 'GET'],
+          query: {
+            foo: 'bar',
+          },
         },
       },
     ]);
   });
+
+  it('should throw when given an invalid query condition', () => {
+    awsCompileAlbEvents.serverless.service.functions = {
+      first: {
+        events: [
+          {
+            alb: {
+              listenerArn: 'arn:aws:elasticloadbalancing:'
+                + 'us-east-1:123456789012:listener/app/my-load-balancer/'
+                + '50dc6c495c0c9188/f2f7dc8efc522ab2',
+              priority: 1,
+              conditions: {
+                path: '/hello',
+                query: 'ss',
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileAlbEvents.validate()).to.throw(Error);
+  });
+
+  it('should throw when given an invalid ip condition', () => {
+    awsCompileAlbEvents.serverless.service.functions = {
+      first: {
+        events: [
+          {
+            alb: {
+              listenerArn: 'arn:aws:elasticloadbalancing:'
+                + 'us-east-1:123456789012:listener/app/my-load-balancer/'
+                + '50dc6c495c0c9188/f2f7dc8efc522ab2',
+              priority: 1,
+              conditions: {
+                path: '/hello',
+                ip: '1.1.1.1',
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileAlbEvents.validate()).to.throw(Error);
+  });
+
+  it('should throw when given an invalid header condition', () => {
+    awsCompileAlbEvents.serverless.service.functions = {
+      first: {
+        events: [
+          {
+            alb: {
+              listenerArn: 'arn:aws:elasticloadbalancing:'
+                + 'us-east-1:123456789012:listener/app/my-load-balancer/'
+                + '50dc6c495c0c9188/f2f7dc8efc522ab2',
+              priority: 1,
+              conditions: {
+                path: '/hello',
+                header: ['foo'],
+              },
+            },
+          },
+        ],
+      },
+    };
+
+    expect(() => awsCompileAlbEvents.validate()).to.throw(Error);
+  });
+
+  describe('#validateIpCondition()', () => {
+    it('should throw if ip is not a valid ipv6 or ipv4 cidr block', () => {
+      const event = { alb: { conditions: { ip: 'fe80:0000:0000:0000:0204:61ff:fe9d:f156/' } } };
+      expect(() => awsCompileAlbEvents.validateIpCondition(event, '')).to.throw(Error);
+    });
+
+    it('should return the value as array if it is a valid ipv6 cidr block', () => {
+      const event = { alb: { conditions: { ip: 'fe80:0000:0000:0000:0204:61ff:fe9d:f156/127' } } };
+      expect(awsCompileAlbEvents.validateIpCondition(event, ''))
+        .to.deep.equal(['fe80:0000:0000:0000:0204:61ff:fe9d:f156/127']);
+    });
+
+    it('should return the value as array if it is a valid ipv4 cidr block', () => {
+      const event = { alb: { conditions: { ip: '192.168.0.1/21' } } };
+      expect(awsCompileAlbEvents.validateIpCondition(event, ''))
+        .to.deep.equal(['192.168.0.1/21']);
+    });
+  });
+
+  describe('#validateQueryCondition()', () => {
+    it('should throw if query is not an object', () => {
+      const event = { alb: { conditions: { query: 'foo' } } };
+      expect(() => awsCompileAlbEvents.validateQueryCondition(event, '')).to.throw(Error);
+    });
+
+    it('should return the value if it is an object', () => {
+      const event = { alb: { conditions: { query: { foo: 'bar' } } } };
+      expect(awsCompileAlbEvents.validateQueryCondition(event, ''))
+        .to.deep.equal({ foo: 'bar' });
+    });
+  });
+
+  describe('#validateHeaderCondition()', () => {
+    it('should throw if header does not have the required properties', () => {
+      const event = { alb: { conditions: { header: { name: 'foo', value: 'bar' } } } };
+      expect(() => awsCompileAlbEvents.validateHeaderCondition(event, '')).to.throw(Error);
+    });
+
+    it('should throw if header.values is not an array', () => {
+      const event = { alb: { conditions: { header: { name: 'foo', values: 'bar' } } } };
+      expect(() => awsCompileAlbEvents.validateHeaderCondition(event, '')).to.throw(Error);
+    });
+
+    it('should return the value if it is valid', () => {
+      const event = { alb: { conditions: { header: { name: 'foo', values: ['bar'] } } } };
+      expect(awsCompileAlbEvents.validateHeaderCondition(event, ''))
+        .to.deep.equal({ name: 'foo', values: ['bar'] });
+    });
+  });
 });