From c96b429c6082f203e1cc06c2ae27a40a8a259bcd Mon Sep 17 00:00:00 2001 From: Oz Weiss Date: Tue, 29 Sep 2020 23:13:25 +0300 Subject: [PATCH] feat(Config Schema): Schema for AWS `alb` event (#8291) --- .../aws/package/compile/events/alb/index.js | 82 ++++++++- .../package/compile/events/alb/index.test.js | 4 +- .../compile/events/alb/lib/validate.js | 85 ++------- .../compile/events/alb/lib/validate.test.js | 167 ------------------ lib/plugins/aws/provider/awsProvider.js | 71 +++++++- lib/plugins/aws/provider/awsProvider.test.js | 7 - 6 files changed, 158 insertions(+), 258 deletions(-) diff --git a/lib/plugins/aws/package/compile/events/alb/index.js b/lib/plugins/aws/package/compile/events/alb/index.js index 403270c4985..ebf3be3e69f 100644 --- a/lib/plugins/aws/package/compile/events/alb/index.js +++ b/lib/plugins/aws/package/compile/events/alb/index.js @@ -7,6 +7,12 @@ const compileTargetGroups = require('./lib/targetGroups'); const compileListenerRules = require('./lib/listenerRules'); const compilePermissions = require('./lib/permissions'); +function arrayOrSingleSchema(schema) { + return { + oneOf: [schema, { type: 'array', items: schema }], + }; +} + class AwsCompileAlbEvents { constructor(serverless, options) { this.serverless = serverless; @@ -28,8 +34,80 @@ class AwsCompileAlbEvents { }, }; - // TODO: Complete schema, see https://github.com/serverless/serverless/issues/8020 - this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'alb', { type: 'object' }); + this.serverless.configSchemaHandler.defineFunctionEvent('aws', 'alb', { + type: 'object', + properties: { + authorizer: arrayOrSingleSchema({ type: 'string' }), + conditions: { + type: 'object', + properties: { + header: { + type: 'object', + properties: { + name: { type: 'string', maxLength: 40 }, + values: { type: 'array', items: { type: 'string', maxLength: 128 } }, + }, + additionalProperties: false, + required: ['name', 'values'], + }, + host: arrayOrSingleSchema({ + type: 'string', + pattern: '^[A-Za-z0-9*?.-]+$', + maxLength: 128, + }), + ip: arrayOrSingleSchema({ + oneOf: [ + { type: 'string', format: 'ipv4' }, + { type: 'string', format: 'ipv6' }, + ], + }), + method: arrayOrSingleSchema({ type: 'string', pattern: '^[A-Z_-]+$', maxLength: 40 }), + path: arrayOrSingleSchema({ + type: 'string', + pattern: '^([A-Za-z0-9*?_.$/~"\'@:+-]|&)+$', + maxLength: 128, + }), + query: { + type: 'object', + additionalProperties: { type: 'string', maxLength: 128 }, + propertyNames: { type: 'string', maxLength: 128 }, + }, + }, + required: ['path'], + additionalProperties: false, + }, + healthCheck: { + oneOf: [ + { type: 'boolean' }, + { + type: 'object', + properties: { + healthyThresholdCount: { type: 'integer', minimum: 2, maximum: 10 }, + intervalSeconds: { type: 'integer', minimum: 5, maximum: 300 }, + matcher: { + type: 'object', + properties: { + httpCode: { type: 'string', pattern: '^\\d{3}(-\\d{3})?(,\\d{3}(-\\d{3})?)*$' }, + }, + additionalProperties: false, + }, + path: { type: 'string', minLength: 1, maxLength: 1024 }, + timeoutSeconds: { type: 'integer', minimum: 2, maximum: 120 }, + unhealthyThresholdCount: { type: 'integer', minimum: 2, maximum: 10 }, + }, + additionalProperties: false, + }, + ], + }, + listenerArn: { + oneOf: [{ $ref: '#/definitions/awsAlbListenerArn' }, { $ref: '#/definitions/awsCfRef' }], + }, + multiValueHeaders: { type: 'boolean' }, + priority: { type: 'integer', minimum: 1, maximum: 50000 }, + }, + required: ['listenerArn', 'priority', 'conditions'], + additionalProperties: false, + }); } } diff --git a/lib/plugins/aws/package/compile/events/alb/index.test.js b/lib/plugins/aws/package/compile/events/alb/index.test.js index 5fae540a142..6c816bf4d2a 100644 --- a/lib/plugins/aws/package/compile/events/alb/index.test.js +++ b/lib/plugins/aws/package/compile/events/alb/index.test.js @@ -93,7 +93,7 @@ describe('AwsCompileAlbEvents', () => { { type: 'cognito', userPoolClientId: 'userPoolClientId', - userPoolArn: 'userPoolArn', + userPoolArn: 'arn:userPoolArn', userPoolDomain: 'userPoolDomain', scope: 'openid', sessionCookieName: 'sessionCookie', @@ -127,7 +127,7 @@ describe('AwsCompileAlbEvents', () => { const baseAuthenticateCognitoConfig = (override = {}) => Object.assign( { - UserPoolArn: 'userPoolArn', + UserPoolArn: 'arn:userPoolArn', UserPoolClientId: 'userPoolClientId', UserPoolDomain: 'userPoolDomain', OnUnauthenticatedRequest: 'deny', 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 f69212cefba..3c5ec24c98e 100644 --- a/lib/plugins/aws/package/compile/events/alb/lib/validate.js +++ b/lib/plugins/aws/package/compile/events/alb/lib/validate.js @@ -2,28 +2,18 @@ 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]))$/; -// see https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#arn-syntax-elb-application -const ALB_LISTENER_PATTERN = /^arn:aws[\w-]*:elasticloadbalancing:.+:listener\/app\/[\w-]+\/([\w-]+)\/([\w-]+)$/; - module.exports = { + // see https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html#arn-syntax-elb-application + ALB_LISTENER_REGEXP: new RegExp( + '^arn:aws[\\w-]*:elasticloadbalancing:.+:listener\\/app\\/[\\w-]+\\/([\\w-]+)\\/([\\w-]+)$' + ), + validate() { const authorizers = {}; const albAuthConfig = this.serverless.service.provider.alb; if (albAuthConfig && albAuthConfig.authorizers) { for (const [name, auth] of Object.entries(albAuthConfig.authorizers)) { - switch (auth.type) { - case 'cognito': - case 'oidc': - authorizers[name] = this.validateAlbAuth(auth); - break; - default: - throw new this.serverless.classes.Error( - `Authorizer type "${auth.type}" not supported. Only "cognito" and "oidc" are supported` - ); - } + authorizers[name] = this.validateAlbAuth(auth); } } @@ -58,13 +48,13 @@ module.exports = { albObj.conditions.method = [].concat(event.alb.conditions.method); } if (event.alb.conditions.header) { - albObj.conditions.header = this.validateHeaderCondition(event, functionName); + albObj.conditions.header = event.alb.conditions.header; } if (event.alb.conditions.query) { - albObj.conditions.query = this.validateQueryCondition(event, functionName); + albObj.conditions.query = event.alb.conditions.query; } if (event.alb.conditions.ip) { - albObj.conditions.ip = this.validateIpCondition(event, functionName); + albObj.conditions.ip = [].concat(event.alb.conditions.ip); } if (event.alb.multiValueHeaders) { albObj.multiValueHeaders = event.alb.multiValueHeaders; @@ -89,21 +79,13 @@ module.exports = { }, validateListenerArn(listenerArn, functionName) { - if (!listenerArn) { - throw new this.serverless.classes.Error( - `listenerArn is missing in function "${functionName}".` - ); - } // If the ARN is a ref, use the logical ID instead of the ALB ID if (_.isObject(listenerArn)) { if (listenerArn.Ref) { return { albId: listenerArn.Ref, listenerId: listenerArn.Ref }; } - throw new this.serverless.classes.Error( - `Invalid ALB listenerArn in function "${functionName}".` - ); } - const matches = listenerArn.match(ALB_LISTENER_PATTERN); + const matches = listenerArn.match(this.ALB_LISTENER_REGEXP); if (!matches) { throw new this.serverless.classes.Error( `Invalid ALB listenerArn in function "${functionName}".` @@ -112,53 +94,6 @@ module.exports = { return { albId: matches[1], listenerId: matches[2] }; }, - 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 (!Array.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; - }, - validatePriorities(albEvents) { let comparator; let duplicates; 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 c096d254d44..6ca0f5c03af 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 @@ -145,94 +145,6 @@ describe('#validate()', () => { }); }); - it('throws an error when type in authorizer is not "cognito" or "oidc"', () => { - awsCompileAlbEvents.serverless.service.functions = {}; - awsCompileAlbEvents.serverless.service.provider.alb = { - authorizers: { - myFirstAuth: { - type: 'unknown_ting', - foo: 'bar', - }, - }, - }; - - expect(() => awsCompileAlbEvents.validate()).to.throw( - 'Authorizer type "unknown_ting" not supported. Only "cognito" and "oidc" are supported' - ); - }); - - 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('#validateListenerArnAndExtractAlbId()', () => { it('returns the alb ID when given a valid listener ARN', () => { const listenerArn = @@ -259,85 +171,6 @@ describe('#validate()', () => { listenerId: 'HTTPListener1', }); }); - - it('throws an error if the listener ARN is missing', () => { - const listenerArns = [undefined, null, false, '']; - listenerArns.forEach(listenerArn => { - expect(() => awsCompileAlbEvents.validateListenerArn(listenerArn, 'functionname')).to.throw( - 'listenerArn is missing in function "functionname".' - ); - }); - }); - - it('throws an error if the listener ARN is invalid', () => { - const listenerArns = [ - // ALB listener rule (not a listener) - 'arn:aws:elasticloadbalancing:us-east-1:123456789012:listener-rule/app/my-load-balancer/50dc6c495c0c9188/f2f7dc8efc522ab2/9683b2d02a6cabee', - // ELB - 'arn:aws:elasticloadbalancing:us-east-1:123456789012:listener/net/my-load-balancer/50dc6c495c0c9188/f2f7dc8efc522ab2', - // Non ec2 ARN - 'arn:aws:iam::123456789012:server-certificate/division_abc/subdivision_xyz/ProdServerCert', - // Object without a ref - { foo: 'bar' }, - ]; - listenerArns.forEach(listenerArn => { - const event = { alb: { listenerArn } }; - expect(() => awsCompileAlbEvents.validateListenerArn(event, 'functionname')).to.throw( - 'Invalid ALB listenerArn in function "functionname".' - ); - }); - }); - }); - - 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'], - }); - }); }); describe('#validatePriorities()', () => { diff --git a/lib/plugins/aws/provider/awsProvider.js b/lib/plugins/aws/provider/awsProvider.js index c8515808c56..aefc85d6755 100644 --- a/lib/plugins/aws/provider/awsProvider.js +++ b/lib/plugins/aws/provider/awsProvider.js @@ -13,6 +13,7 @@ const objectHash = require('object-hash'); const PromiseQueue = require('promise-queue'); const getS3EndpointForRegion = require('../utils/getS3EndpointForRegion'); const readline = require('readline'); +const { ALB_LISTENER_REGEXP } = require('../package/compile/events/alb/lib/validate'); const path = require('path'); const isLambdaArn = RegExp.prototype.test.bind(/^arn:[^:]+:lambda:/); @@ -123,6 +124,54 @@ const impl = { }, }; +const baseAlbAuthorizerProperties = { + onUnauthenticatedRequest: { enum: ['allow', 'authenticate', 'deny'] }, + requestExtraParams: { + type: 'object', + maxProperties: 10, + additionalProperties: { type: 'string' }, + }, + scope: { type: 'string' }, + sessionCookieName: { type: 'string' }, + sessionTimeout: { type: 'integer', minimum: 0 }, +}; + +const oidcAlbAuthorizer = { + type: 'object', + properties: { + type: { const: 'oidc' }, + authorizationEndpoint: { format: 'uri' }, + clientId: { type: 'string' }, + clientSecret: { type: 'string' }, + issuer: { format: 'uri' }, + tokenEndpoint: { format: 'uri' }, + userInfoEndpoint: { format: 'uri' }, + ...baseAlbAuthorizerProperties, + }, + required: [ + 'type', + 'authorizationEndpoint', + 'clientId', + 'issuer', + 'tokenEndpoint', + 'userInfoEndpoint', + ], + additionalProperties: false, +}; + +const cognitoAlbAuthorizer = { + type: 'object', + properties: { + type: { const: 'cognito' }, + userPoolArn: { $ref: '#/definitions/awsArn' }, + userPoolClientId: { type: 'string' }, + userPoolDomain: { type: 'string' }, + ...baseAlbAuthorizerProperties, + }, + required: ['type', 'userPoolArn', 'userPoolClientId', 'userPoolDomain'], + additionalProperties: false, +}; + class AwsProvider { static getProviderName() { return constants.providerName; @@ -142,6 +191,10 @@ class AwsProvider { // TODO: Complete schema, see https://github.com/serverless/serverless/issues/8016 serverless.configSchemaHandler.defineProvider('aws', { definitions: { + awsAlbListenerArn: { + type: 'string', + pattern: ALB_LISTENER_REGEXP.source, + }, awsAlexaEventToken: { type: 'string', minLength: 0, @@ -325,6 +378,19 @@ class AwsProvider { provider: { properties: { apiGateway: { type: 'object', properties: { websocketApiId: { type: 'string' } } }, + alb: { + type: 'object', + properties: { + targetGroupPrefix: { type: 'string', maxLength: 16 }, + authorizers: { + type: 'object', + additionalProperties: { + oneOf: [oidcAlbAuthorizer, cognitoAlbAuthorizer], + }, + }, + }, + additionalProperties: false, + }, environment: { $ref: '#/definitions/awsLambdaEnvironment' }, httpApi: { type: 'object', @@ -1013,11 +1079,6 @@ class AwsProvider { return ''; } - if (provider.alb.targetGroupPrefix.length > 16) { - const errorMessage = `Length of alb.targetGroupPrefix should be at most 16 but is ${provider.alb.targetGroupPrefix.length}`; - throw new this.serverless.classes.Error(errorMessage); - } - return provider.alb.targetGroupPrefix; } diff --git a/lib/plugins/aws/provider/awsProvider.test.js b/lib/plugins/aws/provider/awsProvider.test.js index b8136b63908..03d44a16ac7 100644 --- a/lib/plugins/aws/provider/awsProvider.test.js +++ b/lib/plugins/aws/provider/awsProvider.test.js @@ -1372,13 +1372,6 @@ describe('AwsProvider', () => { expect(awsProvider.getAlbTargetGroupPrefix()).to.equal(''); }); - it('should throw error if prefix is longer than 16', () => { - serverless.service.provider.alb = {}; - serverless.service.provider.alb.targetGroupPrefix = 'my-17-char-prefix'; - - expect(() => awsProvider.getAlbTargetGroupPrefix()).to.throw(Error); - }); - it('should support no prefix', () => { serverless.service.provider.alb = {}; serverless.service.provider.alb.targetGroupPrefix = '';