├── .gitignore ├── .travis.yml ├── Dockerfile ├── README.md ├── docker-compose.yml ├── integration-test ├── .gitignore ├── handler.js ├── package.json ├── send.js ├── serverless.yml ├── sns-filter-it-setup.js ├── template.yml ├── tsconfig.json ├── tsconfig.test.json ├── tslint.json └── yarn.lock ├── package.json ├── plugin ├── .snyk ├── README.md ├── package.json ├── resources.yml ├── spec │ ├── addFilterPolicy.test.ts │ ├── serverless_sns_filter.integration.ts │ ├── snsFilterPlugin.test.ts │ ├── support │ │ ├── jasmine.integration.json │ │ ├── jasmine.json │ │ └── jasmine.wip.json │ └── test-data │ │ ├── filter-policy-request.json │ │ ├── snsListSubscriptions_response_error.json │ │ ├── snsListSubscriptions_response_p1.json │ │ ├── snsListSubscriptions_response_p2.json │ │ └── snsListSubscriptions_response_p3.json ├── src │ ├── addFilterPolicy.js │ ├── index.ts │ ├── snsFilterPlugin.ts │ ├── tsconfig.json │ └── typings │ │ └── index.d.ts ├── tsconfig.json ├── tslint.json └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/ts-node* 3 | 4 | # Node build packages 5 | **/node_modules 6 | **/jspm_packages 7 | 8 | # serverless build 9 | **/.serverless 10 | **/.build/ 11 | **/.vscode 12 | **/node-dependencies* 13 | **/.nyc_output 14 | plugin/dist 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 6 4 | # - node 5 | cache: 6 | directories: 7 | - plugin/node_modules 8 | install: 9 | - cd plugin 10 | - yarn install 11 | script: 12 | # - cd plugin 13 | - yarn run test 14 | - yarn run test:security 15 | env: 16 | global: 17 | secure: jcwXBJzUYy6UgH/bfLKyDuLHxvFDQa4BYdwRo+4mFei7St14PvNpgDZ7YXflmVwdEDFT9w0xAp0pteTyM6XNWf0VvPCSm6Tu5eHqFSRep2tpaO9IWTM2pva7Qx10qiW8KKZ9hs254lXn6vzD88APYYg/jICODiyj0NGa8X0OlNaOL/q/fXrclrZefbNhTRUV8b5rPCjcol2VS769Gpx8r6zqh4AlzeyNyywDMKBPRzzDglOcj45o4+MsSoJHX/J8xhwcEEY3pLo9aXtWKZRGbUzaN2goavlBGLZ3dFV/kNHCYz2+hIpU/pbIsOAkpygMO7fRiBNoPFQQHO07K7m/q+kWE/NPiWpgPIf1/NOjpvGmNutVN/UhgdUp+rqve8esQ/hsroPyszLOTCRxQbgZwpUqTdVBrtCfe5TJwhR5F/SFYhrM96YWtsB/3xXDKXa1/9rSltR9Qha8zHnAwfoUO7UHG+SsBvBctwMHrNnv4n8/2jJ1SVBwW3bG3BzIQ3ngJX/d97K86jYSI9/xYGur5+08wkLPXvbi+ypB0hReXtMp/ayHr6JEWpI8fnOMl4vUTw9vLOHQJd5X2Tt56IrP+FU5dijtG7Kk6bECa0ANy2j52k2+QFP3K2W1ogF545SdjJhaCX5Jz6URaCwWoEJz+zv0fOdliTmpXHxP44vmyro= 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:6.10 2 | 3 | WORKDIR /app 4 | RUN yarn install 5 | 6 | RUN yarn global add serverless 7 | 8 | EXPOSE 9229 9 | 10 | ENTRYPOINT '/bin/bash' 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED - serverless-sns-filter ![build status](https://travis-ci.org/MechanicalRock/serverless-sns-filter.svg?branch=master) 2 | 3 | Before continuing further, SNS Filters are now supported by Serverless Framework: 4 | 5 | You should probably use that: 6 | 7 | https://serverless.com/framework/docs/providers/aws/events/sns/ 8 | 9 | This project is now deprecated. 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | # To use the TravisCI CLI to encrypt values 4 | # https://docs.travis-ci.com/user/encryption-keys/ 5 | # NOTE: Need to install travis cli before this is usable 6 | travis-cli: 7 | image: ruby:latest 8 | entrypoint: 9 | - /bin/bash 10 | working_dir: /app 11 | volumes: 12 | - .:/app 13 | serverless: 14 | build: 15 | context: . 16 | working_dir: /app 17 | volumes: 18 | - .:/app 19 | - ~/.aws/:/root/.aws 20 | - ~/.gitconfig:/root/.gitconfig 21 | environment: 22 | - AWS_ACCESS_KEY_ID 23 | - AWS_SECRET_ACCESS_KEY 24 | - AWS_PROFILE 25 | - AWS_SESSION_TOKEN 26 | - AWS_SECURITY_TOKEN 27 | entrypoint: 28 | - /bin/bash 29 | ports: 30 | - '9229:9229' 31 | localstack: 32 | image: localstack/localstack 33 | ports: 34 | - "4567-4582:4567-4582" 35 | - "8080:8080" 36 | # environment: 37 | # Only start a subset of services required for testing. 38 | # - SERVICES=s3,sns,sqs,apigateway,lambda,dynamodb,dynamodbstreams,cloudformation 39 | # - DEBUG=${DEBUG- } 40 | # - DATA_DIR=${DATA_DIR- } 41 | # - LAMBDA_EXECUTOR=${LAMBDA_EXECUTOR- } 42 | # - KINESIS_ERROR_PROBABILITY=${KINESIS_ERROR_PROBABILITY- } 43 | # - DOCKER_HOST=unix:///var/run/docker.sock 44 | volumes: 45 | # - "${TMP_DIR:-/tmp/localstack}:${TMP_DIR:-/tmp/localstack}" 46 | - "/var/run/docker.sock:/var/run/docker.sock" 47 | -------------------------------------------------------------------------------- /integration-test/.gitignore: -------------------------------------------------------------------------------- 1 | # package directories 2 | node_modules 3 | jspm_packages 4 | 5 | # Serverless directories 6 | .serverless -------------------------------------------------------------------------------- /integration-test/handler.js: -------------------------------------------------------------------------------- 1 | const hello = (event, context, callback) => { 2 | 3 | console.log(`message received: ${event.Records[0].Sns.Message}`); 4 | console.log(`messageId: ${event.Records[0].Sns.MessageId}`); 5 | console.log(`${JSON.stringify(event)}`); 6 | const response = { 7 | statusCode: 200, 8 | body: JSON.stringify({ 9 | message: "Go Serverless v1.0! Your function executed successfully!", 10 | input: event, 11 | }), 12 | }; 13 | 14 | callback(null, response); 15 | 16 | }; 17 | 18 | module.exports = { 19 | hello: hello 20 | } 21 | -------------------------------------------------------------------------------- /integration-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-sns-filter-integration-test", 3 | "version": "0.0.2", 4 | "description": "Integration test for serverless-sns-filter plugin", 5 | "main": "handler.js", 6 | "scripts": { 7 | "setup:dev": "yarn link serverless-sns-filter", 8 | "lint": "tslint -c tslint.json '**/*.ts' --exclude 'node_modules/**'", 9 | "lint:fix": "tslint -c tslint.json '**/*.ts' --exclude 'node_modules/**' --fix", 10 | "test": "jasmine-ts --config=spec/support/jasmine.json", 11 | "test:it": "TMPDIR=/tmp jasmine-ts --config=spec/support/jasmine.integration.json", 12 | "test:wip": "TMPDIR=/tmp jasmine-ts --config=spec/support/jasmine.wip.json", 13 | "test:accept": "TMPDIR=/tmp cucumber-js spec", 14 | "test:watch": "nodemon -L -i spec ./node_modules/.bin/jasmine-ts", 15 | "test:cover": "jasmine-ts --coverage", 16 | "package": "serverless package", 17 | "deploy:dev": "serverless deploy --stage dev" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+ssh://git@TODO" 22 | }, 23 | "author": "Tim Myerscough ", 24 | "contributors": [], 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "TODO" 28 | }, 29 | "homepage": "TODO", 30 | "dependencies": { 31 | "cfn-response": "^1.0.1", 32 | "serverless-sns-filter": "^1.1.3" 33 | }, 34 | "devDependencies": { 35 | "@types/aws-lambda": "^0.0.22", 36 | "@types/chai": "^4.0.8", 37 | "@types/jasmine": "^2.8.2", 38 | "@types/node": "^8.0.55", 39 | "@types/sinon": "^4.0.0", 40 | "@types/sinon-chai": "^2.7.29", 41 | "aws-sdk": "^2.94.0", 42 | "aws-sdk-mock": "^1.7.0", 43 | "chai": "^3.5.0", 44 | "chai-fs": "^1.0.0", 45 | "child_process": "^1.0.2", 46 | "fs-extra": "^0.26.7", 47 | "jasmine": "^2.6.0", 48 | "jasmine-promises": "^0.4.1", 49 | "jasmine-spec-reporter": "^4.1.1", 50 | "jasmine-ts": "^0.2.1", 51 | "nodemon": "^1.11.0", 52 | "request": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", 53 | "request-promise": "^4.1.1", 54 | "serverless": "^1.18.1", 55 | "serverless-plugin-typescript": "^1.1.3", 56 | "serverless-webpack": "^2.0.0", 57 | "sinon": "^2.3.8", 58 | "sinon-chai": "^2.14.0", 59 | "source-map-support": "^0.4.0", 60 | "tslint": "^5.8.0", 61 | "webpack": "^3.4.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /integration-test/send.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk') 2 | let sns = new AWS.SNS() 3 | 4 | const publish_sns = (params, callback) => { 5 | sns.publish(params).promise().then(done => { 6 | console.log(JSON.stringify(done)) 7 | callback(null,done) 8 | }).catch(error => { 9 | console.log(JSON.stringify(error)) 10 | callback(error,"send failed") 11 | }) 12 | } 13 | 14 | const with_attribute = (event, context, callback) => { 15 | const topicArn = process.env['TOPIC_ARN'] 16 | console.log(`publishing to topic: ${topicArn}`) 17 | const params = { 18 | Message: 'should be received', 19 | MessageAttributes: { 20 | 'attrib_one': { 21 | DataType: 'String', 22 | StringValue: 'foo' 23 | } 24 | }, 25 | Subject: "Successful message", 26 | TopicArn: topicArn 27 | } 28 | 29 | publish_sns(params,callback) 30 | 31 | } 32 | 33 | const without_attribute = (event, context, callback) => { 34 | const params = { 35 | Message: 'should be filtered', 36 | Subject: "Filtered message", 37 | TopicArn: process.env['TOPIC_ARN'] 38 | } 39 | 40 | publish_sns(params,callback) 41 | } 42 | 43 | module.exports = { 44 | without_attribute: without_attribute, 45 | with_attribute: with_attribute, 46 | publish_sns: publish_sns 47 | } 48 | -------------------------------------------------------------------------------- /integration-test/serverless.yml: -------------------------------------------------------------------------------- 1 | 2 | service: sls-plugin-it 3 | 4 | plugins: 5 | - serverless-sns-filter 6 | 7 | package: 8 | exclude: 9 | - 'node_modules/**' 10 | - '!node_modules/cfn-response/**' 11 | 12 | provider: 13 | name: aws 14 | region: ap-southeast-2 15 | runtime: nodejs6.10 16 | variableSyntax: "\\${{([ ~:a-zA-Z0-9._\\'\",\\-\\/\\(\\)]+?)}}" 17 | iamRoleStatements: 18 | - Effect: Allow 19 | Action: 20 | - SNS:Publish 21 | Resource: { "Fn::Join" : ["", ["arn:aws:sns:${{self:custom.region}}:", { "Ref" : "AWS::AccountId" }, ":${{self:custom.greeterTopic}}" ] ] } 22 | # Permissions required to by the SNS Filter Policy custom resource Lambda 23 | - Effect: Allow 24 | Action: 25 | - SNS:ListSubscriptions 26 | Resource: 27 | - { "Fn::Join" : ["", ["arn:aws:sns:${{self:custom.region}}:", { "Ref" : "AWS::AccountId" }, ":*" ] ] } 28 | - Effect: Allow 29 | Action: 30 | - SNS:setSubscriptionAttributes 31 | Resource: 32 | - { "Fn::Join" : ["", ["arn:aws:sns:${{self:custom.region}}:", { "Ref" : "AWS::AccountId" }, ":${{self:custom.greeterTopic}}" ] ] } 33 | 34 | custom: 35 | stage: ${{opt:stage, self:provider.stage}} 36 | region: ${{opt:region, self:provider.region}} 37 | greeterTopic: "${{self:service}}-greeter-${{self:custom.stage}}" 38 | greeterTopicArn: 39 | Fn::Join: 40 | - '' 41 | - - "arn:aws:sns:${{self:custom.region}}:" 42 | - Ref: AWS::AccountId 43 | - ":${{self:custom.greeterTopic}}" 44 | 45 | functions: 46 | helloPreexisting: 47 | handler: handler.hello 48 | events: 49 | - sns: 50 | arn: 51 | Fn::Join: 52 | - '' 53 | - - "arn:aws:sns:${{self:custom.region}}:" 54 | - Ref: AWS::AccountId 55 | - ":prexisting-topic" 56 | topicName: prexisting-topic 57 | # filter policy to accept messages with attrib_one values including "foo" OR "bar" 58 | filter: 59 | attrib_one: 60 | - foo 61 | - bar 62 | hello: 63 | handler: handler.hello 64 | events: 65 | - sns: ${{self:custom.greeterTopic}} 66 | # filter policy to accept messages with attrib_one values including "foo" OR "bar" 67 | filter: 68 | attrib_one: 69 | - foo 70 | - bar 71 | sendMessage: 72 | handler: send.with_attribute 73 | environment: 74 | TOPIC_ARN: "${{self:custom.greeterTopicArn}}" 75 | 76 | sendFilteredMessage: 77 | handler: send.without_attribute 78 | environment: 79 | TOPIC_ARN: "${{self:custom.greeterTopicArn}}" 80 | -------------------------------------------------------------------------------- /integration-test/sns-filter-it-setup.js: -------------------------------------------------------------------------------- 1 | #! /usr/local/bin/node 2 | const AWS=require('aws-sdk') 3 | const fs=require('fs') 4 | const path=require('path') 5 | 6 | cf = new AWS.CloudFormation({ 7 | region: 'ap-southeast-2', 8 | }) 9 | 10 | let templateBody=fs.readFileSync(path.resolve(__dirname, 'template.yml')) 11 | console.log(`deploying ${templateBody}`) 12 | const params={ 13 | StackName: 'sns-filter-it-setup', 14 | TemplateBody: templateBody.toString() 15 | } 16 | 17 | cf.createStack(params).promise().then(console.log) -------------------------------------------------------------------------------- /integration-test/template.yml: -------------------------------------------------------------------------------- 1 | Resources: 2 | # TODO - create SNS Topic 3 | PreExistingTopic: 4 | Type: "AWS::SNS::Topic" 5 | Properties: 6 | TopicName: "prexisting-topic" 7 | Outputs: 8 | PreExistingTopicArn: 9 | Description: A Topic, imported by the serverless-sns-filter integration test project 10 | Value: !Ref PreExistingTopic -------------------------------------------------------------------------------- /integration-test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "outDir": ".build", 8 | "moduleResolution": "node", 9 | "lib": ["es2015"], 10 | "rootDir": "./" 11 | }, 12 | "exclude": [ 13 | "./node_modules", 14 | ".serverless" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /integration-test/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /integration-test/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended"] 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ci-build", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "git@github.com:MechanicalRock/serverless-sns-filter.git", 6 | "author": "temyers ", 7 | "license": "MIT" 8 | } 9 | -------------------------------------------------------------------------------- /plugin/.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.10.1 3 | # ignores vulnerabilities until expiry date; change duration by modifying expiry date 4 | ignore: 5 | 'npm:lodash:20180130': 6 | - babel-generator > lodash: 7 | reason: 'Not available, ATM' 8 | expires: '2018-03-21T05:58:50.790Z' 9 | - babel-generator > babel-types > lodash: 10 | reason: Not available. Upgrated direct dep 11 | expires: '2018-03-21T05:58:50.790Z' 12 | - babel-template > lodash: 13 | reason: Not avail. 14 | expires: '2018-03-21T05:58:50.791Z' 15 | - babel-template > babel-types > lodash: 16 | reason: Not Avail. 17 | expires: '2018-03-21T05:58:50.791Z' 18 | - babel-template > babel-traverse > lodash: 19 | reason: Not Avail 20 | expires: '2018-03-21T05:58:50.791Z' 21 | - babel-template > babel-traverse > babel-types > lodash: 22 | reason: Not Avail 23 | expires: '2018-03-21T05:58:50.791Z' 24 | - istanbul-lib-instrument > babel-types > lodash: 25 | reason: Not avail 26 | expires: '2018-03-21T05:58:50.792Z' 27 | - istanbul-lib-instrument > babel-generator > lodash: 28 | reason: Not avail 29 | expires: '2018-03-21T05:58:50.792Z' 30 | - istanbul-lib-instrument > babel-generator > babel-types > lodash: 31 | reason: Not avail 32 | expires: '2018-03-21T05:58:50.792Z' 33 | - istanbul-lib-instrument > babel-traverse > lodash: 34 | reason: Not avail 35 | expires: '2018-03-21T05:58:50.792Z' 36 | - istanbul-lib-instrument > babel-traverse > babel-types > lodash: 37 | reason: Not avail 38 | expires: '2018-03-21T05:58:50.792Z' 39 | - istanbul-lib-instrument > babel-template > lodash: 40 | reason: Not avail 41 | expires: '2018-03-21T05:58:50.792Z' 42 | - istanbul-lib-instrument > babel-template > babel-types > lodash: 43 | reason: Not avail 44 | expires: '2018-03-21T05:58:50.792Z' 45 | - istanbul-lib-instrument > babel-template > babel-traverse > lodash: 46 | reason: Not avail 47 | expires: '2018-03-21T05:58:50.792Z' 48 | - istanbul-lib-instrument > babel-template > babel-traverse > babel-types > lodash: 49 | reason: Not avail 50 | expires: '2018-03-21T05:58:50.792Z' 51 | patch: {} 52 | -------------------------------------------------------------------------------- /plugin/README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED serverless-sns-filter 2 | 3 | Before continuing further, SNS Filters are now supported by Serverless Framework: 4 | 5 | You should probably use that: 6 | 7 | https://serverless.com/framework/docs/providers/aws/events/sns/ 8 | 9 | This project is now deprecated. 10 | -------------------------------------------------------------------------------- /plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serverless-sns-filter", 3 | "version": "1.1.6", 4 | "description": "Plugin to add SNS Subscription Fiters to a serverless SNS event", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "setup:dev": "yarn link", 8 | "build": "tsc -p src", 9 | "lint": "tslint -c tslint.json '**/*.ts' --exclude 'node_modules/**'", 10 | "lint:fix": "tslint -c tslint.json '**/*.ts' --exclude 'node_modules/**' --fix", 11 | "test": "nyc -e .ts -x \"*.test.ts\" jasmine-ts --config=spec/support/jasmine.json", 12 | "test:accept": "TMPDIR=/tmp cucumber-js spec", 13 | "test:it": "TMPDIR=/tmp jasmine-ts --config=spec/support/jasmine.integration.json", 14 | "test:wip": "TMPDIR=/tmp nyc -e .ts jasmine-ts --config=spec/support/jasmine.wip.json", 15 | "test:security": "snyk test", 16 | "test:watch": "nodemon -L -i spec ./node_modules/.bin/jasmine-ts", 17 | "package": "serverless package", 18 | "deploy:dev": "serverless deploy --stage dev", 19 | "prepublish": "yarn run build && yarn run test", 20 | "preversion": "yarn run build && yarn run test", 21 | "debug:remote": "node --inspect=0.0.0.0:9229 --debug-brk index.js" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+ssh://git@github.com:MechanicalRock/serverless-sns-filter.git" 26 | }, 27 | "author": "Tim Myerscough ", 28 | "contributors": [], 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/MechanicalRock/serverless-sns-filter" 32 | }, 33 | "homepage": "https://github.com/MechanicalRock/serverless-sns-filter", 34 | "dependencies": { 35 | "cfn-response": "^1.0.1", 36 | "lodash": "4.17.5", 37 | "yamljs": "^0.3.0" 38 | }, 39 | "peerDependencies.Disabled": { 40 | "serverless": ">= 1.12.1" 41 | }, 42 | "devDependencies": { 43 | "@types/aws-lambda": "^0.0.22", 44 | "@types/chai": "^4.0.8", 45 | "@types/jasmine": "^2.8.2", 46 | "@types/lodash": "^4.14.88", 47 | "@types/node": "^8.0.55", 48 | "@types/sinon": "^4.0.0", 49 | "@types/sinon-chai": "^2.7.29", 50 | "aws-sdk": "^2.94.0", 51 | "aws-sdk-mock": "^1.7.0", 52 | "chai": "^3.5.0", 53 | "chai-fs": "^1.0.0", 54 | "child_process": "^1.0.2", 55 | "fs-extra": "^0.26.7", 56 | "jasmine": "^2.6.0", 57 | "jasmine-promises": "^0.4.1", 58 | "jasmine-spec-reporter": "^4.1.1", 59 | "jasmine-ts": "^0.2.1", 60 | "nodemon": "^1.11.0", 61 | "nyc": "^11.3.0", 62 | "request": "https://registry.npmjs.org/request/-/request-2.79.0.tgz", 63 | "request-promise": "^4.1.1", 64 | "serverless": "^1.18.1", 65 | "serverless-plugin-typescript": "^1.1.3", 66 | "serverless-webpack": "^2.0.0", 67 | "sinon": "^2.3.8", 68 | "sinon-chai": "^2.14.0", 69 | "snyk": "^1.69.7", 70 | "source-map-support": "^0.4.0", 71 | "test-exclude": "^4.2.1", 72 | "ts-node": "^4.0.1", 73 | "tslint": "^5.8.0", 74 | "typescript": "^2.6.2", 75 | "webpack": "^3.4.0" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /plugin/resources.yml: -------------------------------------------------------------------------------- 1 | --- 2 | Resources: 3 | IamRoleAddFilterPolicyExecution: 4 | Type: AWS::IAM::Role 5 | Properties: 6 | AssumeRolePolicyDocument: 7 | Version: '2012-10-17' 8 | Statement: 9 | - Effect: Allow 10 | Principal: 11 | Service: 12 | - lambda.amazonaws.com 13 | Action: 14 | - sts:AssumeRole 15 | Policies: 16 | - PolicyName: 'fill-me-in' 17 | PolicyDocument: 18 | Version: '2012-10-17' 19 | Statement: 20 | - Effect: Allow 21 | Action: 22 | - logs:CreateLogStream 23 | Resource: 24 | - Fn::GetAtt: AddFilterPolicyLogGroup.Arn 25 | - Effect: Allow 26 | Action: 27 | - logs:PutLogEvents 28 | Resource: 29 | - Fn::Join: 30 | - "" 31 | - - Fn::GetAtt: AddFilterPolicyLogGroup.Arn 32 | - ':*' 33 | # Fn::GetAtt: AddFilterPolicyLogGroup.Arn 34 | - Effect: Allow 35 | Action: 36 | - SNS:ListSubscriptions 37 | Resource: 38 | - Fn::Sub: arn:aws:sns:${AWS::Region}:${AWS::AccountId}:* 39 | - Effect: Allow 40 | Action: 41 | - SNS:setSubscriptionAttributes 42 | Resource: 43 | # TODO - generate - limit to the TOPIC subscription 44 | Fn::Sub: arn:aws:sns:${AWS::Region}:${AWS::AccountId}:* 45 | Path: "/" 46 | RoleName: 47 | # TODO 48 | # Max 64 chars... 49 | fill-me-in 50 | 51 | AddFilterPolicyLogGroup: 52 | Type: "AWS::Logs::LogGroup" 53 | Properties: 54 | LogGroupName: "/aws/lambda/fill-me-in" 55 | 56 | 57 | AddFilterPolicyLambdaFunction: 58 | Type: AWS::Lambda::Function 59 | Properties: 60 | Code: 61 | # TODO - generate from addFilterPolicy.js 62 | ZipFile: > 63 | const response = require('cfn-response'); 64 | var AWS = require('aws-sdk'); 65 | 66 | const on_event = (event, context, callback) => { 67 | 68 | let subscriptionArn = event.sns_subscription 69 | console.log(`Setting Filter policy '${JSON.stringify(event.filter_policy)}' for subscription '${subscriptionArn}'`) 70 | const sns = new AWS.SNS() 71 | sns.setSubscriptionAttributes({ 72 | AttributeName: 'FilterPolicy', 73 | SubscriptionArn: subscriptionArn, 74 | AttributeValue: JSON.stringify(event.filter_policy) 75 | }).promise().then(result => { callback(null, result) }).catch(err => { callback(err) }) 76 | } 77 | 78 | const do_get_function_subscription = (sns, sns_topic, function_arn, nextPage) => { 79 | return sns.listSubscriptions({ NextToken: nextPage }).promise().then(response => { 80 | nextPage = response.NextToken 81 | if (response.Subscriptions) { 82 | var matchingSubscription = response.Subscriptions.filter(subscription => (subscription.TopicArn === sns_topic) && (subscription.Endpoint === function_arn))[0] 83 | if (matchingSubscription) { 84 | return matchingSubscription.SubscriptionArn 85 | } 86 | } 87 | 88 | if (nextPage) { 89 | return do_get_function_subscription(sns, sns_topic, function_arn, nextPage) 90 | } else { 91 | throw new Error(`no matching subscription for topic ${sns_topic} and function ${function_arn}`) 92 | } 93 | }) 94 | } 95 | 96 | function get_function_subscription(sns_topic, function_arn) { 97 | // TODO - refactor to construct SNS once 98 | const sns = new AWS.SNS() 99 | return new Promise((accept, reject) => { 100 | 101 | do_get_function_subscription(sns, sns_topic, function_arn).then(accept).catch(reject) 102 | 103 | }) 104 | } 105 | 106 | function custom_resource_event(event, context, callback) { 107 | 108 | console.log(JSON.stringify(event)) 109 | let physicalResouceId = context.logStreamName; 110 | 111 | // Delete events don't need to be supported - they shall be deleted when the subscription is deleted. 112 | // AWS currently does not support removing filters :( 113 | if (event.RequestType === 'Create' || event.RequestType === 'Update') { 114 | let filterRequestEvent; 115 | get_function_subscription(event.ResourceProperties.sns_topicArn, event.ResourceProperties.functionArn).then(subscription_arn => { 116 | filterRequestEvent = { 117 | sns_subscription: subscription_arn, 118 | filter_policy: JSON.parse(event.ResourceProperties.filter_policy) 119 | } 120 | 121 | on_event(filterRequestEvent, context, (error, result) => { 122 | if (error) { 123 | response.send(event, context, response.FAILED, { error: error }, physicalResouceId) 124 | 125 | callback(error) 126 | } else { 127 | response.send(event, context, response.SUCCESS, { result: result }, physicalResouceId) 128 | callback(null, "ok") 129 | } 130 | }) 131 | 132 | }).catch(err => { 133 | response.send(event, context, response.FAILED, { error: err }, physicalResouceId) 134 | callback(err) 135 | }) 136 | } else { 137 | response.send(event, context, response.SUCCESS, { result: 'delete not supported - To remove a SubscriptionFilter, delete the Topic Subscription' }, physicalResouceId) 138 | callback(null, "ok") 139 | } 140 | 141 | } 142 | 143 | module.exports = { 144 | custom_resource_event: custom_resource_event, 145 | get_function_subscription: get_function_subscription, 146 | on_event: on_event 147 | } 148 | 149 | # generate based on serverless project 150 | FunctionName: fill-me-in 151 | Handler: index.custom_resource_event 152 | MemorySize: 1024 153 | Role: 154 | Fn::GetAtt: 155 | - IamRoleAddFilterPolicyExecution 156 | - Arn 157 | Runtime: nodejs6.10 158 | Timeout: 6 159 | DependsOn: 160 | - IamRoleAddFilterPolicyExecution 161 | - AddFilterPolicyLogGroup 162 | AddFilterPolicyLambdaFunctionVersion: 163 | Type: AWS::Lambda::Version 164 | DeletionPolicy: Retain 165 | Properties: 166 | FunctionName: 167 | Ref: AddFilterPolicyLambdaFunction 168 | 169 | -------------------------------------------------------------------------------- /plugin/spec/addFilterPolicy.test.ts: -------------------------------------------------------------------------------- 1 | import child = require('child_process') 2 | import chai = require('chai') 3 | import sinonChai = require('sinon-chai') 4 | import * as sinon from 'sinon' 5 | import * as handler from '../src/addFilterPolicy' 6 | import AWSMock = require('aws-sdk-mock') 7 | import * as AWS from 'aws-lambda' 8 | import * as sdk from 'aws-sdk' 9 | 10 | import * as response from 'cfn-response' 11 | // TODO - temporary 12 | // import * as path from 'path' 13 | // AWSMock.setSDK(path.resolve(__dirname, '../../integration-test/node_modules/aws-sdk')); 14 | // import * as response from '../../integration-test/node_modules/cfn-response' 15 | 16 | chai.use(sinonChai) 17 | const expect = chai.expect 18 | 19 | describe('addFilterPolicy', () => { 20 | 21 | describe('#on_event()', () => { 22 | 23 | describe('when a valid event is supplied', () => { 24 | let mockSubscriptionArn = 'arn:aws:sns:us-east-1:123456789012:topic-name:subscription-guid' 25 | let event = { 26 | sns_subscription: mockSubscriptionArn, 27 | filter_policy: { 28 | "attrib_one": ["foo", "bar"] 29 | }, 30 | } 31 | 32 | beforeEach(done => { 33 | let mockCtx: any = { 34 | done: sinon.spy(), 35 | logStreamName: 'myLogStream' 36 | } 37 | this.mockCallback = (err, data) => { 38 | if (err) { done.fail() } else { done() } 39 | 40 | } 41 | this.snsSpy = { 42 | called: false, 43 | params: undefined, 44 | } 45 | var that = this 46 | AWSMock.mock("SNS", 'setSubscriptionAttributes', (params, callback) => { 47 | that.snsSpy.called = true 48 | that.snsSpy.params = params 49 | callback(null, 'fake_result') 50 | }) 51 | 52 | handler.on_event(event, this.mockCtx, this.mockCallback) 53 | 54 | }) 55 | 56 | afterEach(() => { 57 | AWSMock.restore("SNS", 'setSubscriptionAttributes') 58 | }) 59 | 60 | it('should add the filter policy to the SNS subscription', () => { 61 | 62 | let expectedParams = { 63 | AttributeName: 'FilterPolicy', 64 | SubscriptionArn: mockSubscriptionArn, 65 | AttributeValue: "{\"attrib_one\":[\"foo\",\"bar\"]}" 66 | } 67 | expect(this.snsSpy.called).to.be.true 68 | expect(this.snsSpy.params).to.deep.equal(expectedParams) 69 | }) 70 | 71 | }) 72 | 73 | describe('when an invalid event is supplied', () => { 74 | 75 | it('should fail when sns_subscription is undefined', (done) => { 76 | let malformedEvent: any = { 77 | sns_subscription: undefined, 78 | filter_policy: { 79 | "attrib_one": ["foo", "bar"] 80 | } 81 | } 82 | 83 | let mockCallback = (error, response) => { 84 | expect(error).not.to.be.undefined 85 | done() 86 | } 87 | handler.on_event(malformedEvent, undefined as any, mockCallback) 88 | }, 5000) 89 | 90 | it('should fail when filter_policy is undefined', done => { 91 | let malformedEvent: any = { 92 | sns_subscription: "foo", 93 | filter_policy: undefined 94 | } 95 | 96 | let mockCallback = (error, response) => { 97 | expect(error).not.to.be.undefined 98 | done() 99 | } 100 | handler.on_event(malformedEvent, undefined as any, mockCallback) 101 | }) 102 | }) 103 | }) 104 | 105 | }) 106 | 107 | describe('#custom_resource_event', () => { 108 | let mockSubscriptionArn = 'arn:aws:sns:ap-southeast-2:012345678901:testTopic:5f7e20d8-21f5-44c9-b9f6-a74cecb04aff' 109 | let expectedsetSubscriptionAttributeParams = { 110 | AttributeName: 'FilterPolicy', 111 | SubscriptionArn: mockSubscriptionArn, 112 | AttributeValue: "{\"attrib_one\":[\"foo\",\"bar\"]}" 113 | } 114 | 115 | 116 | beforeEach(() => { 117 | let mockSnsSubscriptions = require('./test-data/snsListSubscriptions_response_p1.json') 118 | 119 | AWSMock.mock("SNS", 'listSubscriptions', mockSnsSubscriptions) 120 | this.responseSpy = sinon.stub(response, 'send') 121 | }) 122 | 123 | afterEach(() => { 124 | AWSMock.restore("SNS", 'listSubscriptions') 125 | this.responseSpy.restore() 126 | }) 127 | 128 | describe('SNS API interaction', () => { 129 | beforeEach(() => { 130 | this.mockCtx = { 131 | done: sinon.spy(), 132 | logStreamName: 'myLogStream' 133 | } 134 | this.mockCallback = sinon.spy() 135 | this.snsSpy = sinon.spy() 136 | 137 | this.snsSpy = { 138 | called: false, 139 | params: undefined, 140 | } 141 | let that = this 142 | AWSMock.mock("SNS", 'setSubscriptionAttributes', (params, callback) => { 143 | that.snsSpy.called = true 144 | that.snsSpy.params = params 145 | callback(null, 'fake_result') 146 | }) 147 | 148 | }) 149 | 150 | afterEach(() => { 151 | AWSMock.restore("SNS", 'setSubscriptionAttributes') 152 | }) 153 | 154 | it('should pass the ResourceProperties to AWS API when called with CloudFormationCustomResourceCreateEvent', done => { 155 | let event: any = { 156 | RequestType: "Create", 157 | ResponseURL: "http://pre-signed-S3-url-for-response", 158 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 159 | RequestId: "unique id for this create request", 160 | ResourceType: "Custom::TestResource", 161 | LogicalResourceId: "MyTestResource", 162 | ServiceToken: 'some token', 163 | ResourceProperties: { 164 | ServiceToken: 'some token', 165 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 166 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 167 | filter_policy: "{ \"attrib_one\": [\"foo\", \"bar\"] }" 168 | } 169 | } 170 | 171 | handler.custom_resource_event(event, this.mockCtx, (err, data) => { 172 | expect(this.responseSpy).to.have.been.called 173 | 174 | expect(this.snsSpy.called).to.be.true 175 | expect(this.snsSpy.params).to.deep.equal(expectedsetSubscriptionAttributeParams) 176 | 177 | done() 178 | }) 179 | }) 180 | 181 | it('should pass the ResourceProperties to AWS API when called with CloudFormationCustomResourceUpdateEvent', done => { 182 | let event: any = { 183 | RequestType: "Update", 184 | ResponseURL: "http://pre-signed-S3-url-for-response", 185 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 186 | RequestId: "unique id for this create request", 187 | ResourceType: "Custom::TestResource", 188 | LogicalResourceId: "MyTestResource", 189 | ServiceToken: 'some token', 190 | ResourceProperties: { 191 | ServiceToken: 'some token', 192 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 193 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 194 | filter_policy: "{ \"attrib_one\": [\"foo\", \"bar\"] }" 195 | } 196 | } 197 | 198 | handler.custom_resource_event(event, this.mockCtx, (err, data) => { 199 | expect(this.snsSpy.called).to.be.true 200 | expect(this.snsSpy.params).to.deep.equal(expectedsetSubscriptionAttributeParams) 201 | done() 202 | }) 203 | }) 204 | 205 | describe('when called with CloudFormationCustomResourceDeleteEvent', () => { 206 | let event: any = { 207 | RequestType: "Delete", 208 | ResponseURL: "http://pre-signed-S3-url-for-response", 209 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 210 | RequestId: "unique id for this create request", 211 | ResourceType: "Custom::TestResource", 212 | LogicalResourceId: "MyTestResource", 213 | PhysicalResourceId: "Some ARN", 214 | ServiceToken: 'some token', 215 | ResourceProperties: { 216 | ServiceToken: 'some token', 217 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 218 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 219 | filter_policy: "{ \"attrib_one\": [\"foo\", \"bar\"] }" 220 | } 221 | } 222 | 223 | it('should not invoke AWS API', done => { 224 | handler.custom_resource_event(event, this.mockCtx, (err, data) => { 225 | expect(this.snsSpy.called).to.be.false 226 | done() 227 | }) 228 | }) 229 | 230 | }) 231 | }) 232 | 233 | it('should fail if the filter_policy is malformed', (done) => { 234 | let event: any = { 235 | RequestType: "Create", 236 | ResponseURL: "http://pre-signed-S3-url-for-response", 237 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 238 | RequestId: "unique id for this create request", 239 | ResourceType: "Custom::TestResource", 240 | LogicalResourceId: "MyTestResource", 241 | ServiceToken: 'some token', 242 | ResourceProperties: { 243 | ServiceToken: 'some token', 244 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 245 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 246 | filter_policy: "malformed json" 247 | } 248 | } 249 | 250 | 251 | 252 | handler.custom_resource_event(event, this.mockCtx, (error, resp) => { 253 | expect(error).not.to.be.undefined 254 | 255 | expect(this.responseSpy).to.have.been.called 256 | expect(this.responseSpy).to.have.been.calledWith(event, this.mockCtx, response.FAILED, sinon.match.any, "myLogStream") 257 | done() 258 | }) 259 | }) 260 | 261 | 262 | describe('when AWS request succeeds', () => { 263 | 264 | beforeEach(() => { 265 | this.snsMock = (params, callback) => { callback(null, "some response") } 266 | AWSMock.mock("SNS", 'setSubscriptionAttributes', this.snsMock) 267 | }) 268 | 269 | afterEach(() => { 270 | AWSMock.restore("SNS", 'setSubscriptionAttributes') 271 | }) 272 | 273 | let mockSubscriptionArn = 'arn:aws:sns:us-east-1:123456789012:topic-name:subscription-guid' 274 | let expectedsetSubscriptionAttributeParams = { 275 | AttributeName: 'FilterPolicy', 276 | SubscriptionArn: mockSubscriptionArn, 277 | AttributeValue: "{\"attrib_one\":[\"foo\",\"bar\"]}" 278 | } 279 | 280 | it('should return a CloudFormationCustomResourceSuccessResponse response', done => { 281 | let event: any = { 282 | RequestType: "Create", 283 | ResponseURL: "http://pre-signed-S3-url-for-response", 284 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 285 | RequestId: "unique id for this create request", 286 | ResourceType: "Custom::TestResource", 287 | LogicalResourceId: "MyTestResource", 288 | ServiceToken: 'some token', 289 | ResourceProperties: { 290 | ServiceToken: 'some token', 291 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 292 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 293 | filter_policy: "{ \"attrib_one\": [\"foo\", \"bar\"] }" 294 | } 295 | } 296 | handler.custom_resource_event(event, this.mockCtx, (error, result) => { 297 | expect(error).to.be.null 298 | 299 | expect(this.responseSpy).to.have.been.called 300 | expect(this.responseSpy).to.have.been.calledWith(event, this.mockCtx, response.SUCCESS, sinon.match.any, "myLogStream") 301 | done() 302 | }) 303 | }) 304 | }) 305 | 306 | describe('when AWS request fails', () => { 307 | 308 | beforeEach(() => { 309 | this.mockCtx = { 310 | done: sinon.spy(), 311 | logStreamName: 'myLogStream' 312 | } 313 | this.mockCallback = sinon.spy() 314 | this.snsMock = (params, callback) => { callback("some error", "some response") } 315 | AWSMock.mock("SNS", 'setSubscriptionAttributes', this.snsMock) 316 | }) 317 | 318 | afterEach(() => { 319 | AWSMock.restore("SNS", 'setSubscriptionAttributes') 320 | }) 321 | 322 | let mockSubscriptionArn = 'arn:aws:sns:us-east-1:123456789012:topic-name:subscription-guid' 323 | let expectedsetSubscriptionAttributeParams = { 324 | AttributeName: 'FilterPolicy', 325 | SubscriptionArn: mockSubscriptionArn, 326 | AttributeValue: "{\"attrib_one\":[\"foo\",\"bar\"]}" 327 | } 328 | 329 | it('should return a CloudFormationCustomResourceFailedResponse response', done => { 330 | let event: any = { 331 | RequestType: "Create", 332 | ResponseURL: "http://pre-signed-S3-url-for-response", 333 | StackId: "arn:aws:cloudformation:us-west-2:EXAMPLE/stack-name/guid", 334 | RequestId: "unique id for this create request", 335 | ResourceType: "Custom::TestResource", 336 | LogicalResourceId: "MyTestResource", 337 | ServiceToken: 'some token', 338 | ResourceProperties: { 339 | ServiceToken: 'some token', 340 | sns_topicArn: 'arn:aws:sns:ap-southeast-2:012345678901:testTopic', 341 | functionArn: 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function', 342 | filter_policy: "{ \"attrib_one\": [\"foo\", \"bar\"] }" 343 | } 344 | } 345 | handler.custom_resource_event(event, this.mockCtx, (error, result) => { 346 | expect(error).to.equal("some error") 347 | 348 | expect(this.responseSpy).to.have.been.called 349 | expect(this.responseSpy).to.have.been.calledWith(event, this.mockCtx, response.FAILED, sinon.match.any, "myLogStream") 350 | done() 351 | }) 352 | }) 353 | }) 354 | 355 | }) 356 | 357 | describe('#get_function_subscription()', () => { 358 | describe('when results are paged', () => { 359 | beforeEach(() => { 360 | let mockSnsSubscriptions = require('./test-data/snsListSubscriptions_response_p1.json') 361 | let mockSnsSubscriptionsP2 = require('./test-data/snsListSubscriptions_response_p2.json') 362 | let mockSnsSubscriptionsP3 = require('./test-data/snsListSubscriptions_response_p3.json') 363 | this.snsSubscriptionsSpy = sinon.stub().returns({ 364 | promise: () => { 365 | return new Promise(resolve => resolve(mockSnsSubscriptions)) 366 | } 367 | }) 368 | 369 | 370 | AWSMock.mock("SNS", 'listSubscriptions', (params, callback) => { 371 | switch (params.NextToken) { 372 | case 'AAHJm4\/\/eGaqE5Jui+oa0yy8ycQV\/f8Cf8GYbWxFsGRUWw==': callback(null, mockSnsSubscriptionsP2) 373 | case 'page3': callback(null, mockSnsSubscriptionsP3) 374 | default: callback(null, mockSnsSubscriptions) 375 | } 376 | }) 377 | }) 378 | 379 | afterEach(() => { 380 | AWSMock.restore("SNS", 'listSubscriptions') 381 | }) 382 | 383 | it('should return the promise of a string', () => { 384 | handler.get_function_subscription('foo', 'bar').then(result => { 385 | expect(result).to.be.an.instanceof(String) 386 | }) 387 | }) 388 | 389 | describe('when the target subscription is in the first page', () => { 390 | let topicArn = 'arn:aws:sns:ap-southeast-2:012345678901:testTopic' 391 | let functionArn = 'arn:aws:lambda:ap-southeast-2:012345678901:function:test-function' 392 | 393 | it('should return the sns subscription', (done) => { 394 | handler.get_function_subscription(topicArn, functionArn).then(result => { 395 | expect(result).to.equal('arn:aws:sns:ap-southeast-2:012345678901:testTopic:5f7e20d8-21f5-44c9-b9f6-a74cecb04aff') 396 | }).then(done).catch(done.fail) 397 | }) 398 | 399 | }) 400 | 401 | describe('when the target subscription is in the second page', () => { 402 | let topicArn = 'arn:aws:sns:ap-southeast-2:012345678901:p2_topic' 403 | let functionArn = 'arn:aws:lambda:ap-southeast-2:012345678901:function:p2_function' 404 | 405 | it('should return the sns subscription', (done) => { 406 | handler.get_function_subscription(topicArn, functionArn).then(result => { 407 | expect(result).to.equal('arn:aws:sns:ap-southeast-2:012345678901:p2_topic:365c86bd-f350-498a-9987-deba50e3685f') 408 | }).then(done).catch(done.fail) 409 | }) 410 | 411 | }) 412 | 413 | describe('when the target subscription is after the second page', () => { 414 | let topicArn = 'arn:aws:sns:ap-southeast-2:012345678901:p3_topic' 415 | let functionArn = 'arn:aws:lambda:ap-southeast-2:012345678901:function:p3_function' 416 | 417 | it('should return the sns subscription', (done) => { 418 | handler.get_function_subscription(topicArn, functionArn).then(result => { 419 | expect(result).to.equal('arn:aws:sns:ap-southeast-2:012345678901:p3_topic:365c86bd-f350-498a-9987-deba50e3685f') 420 | }).then(done).catch(done.fail) 421 | }) 422 | 423 | }) 424 | 425 | describe('when the target subscription is not in the results', () => { 426 | it('should throw Error', done => { 427 | let topicArn = 'not_found' 428 | let functionArn = 'arn:aws:lambda:ap-southeast-2:012345678901:function:p3_function' 429 | 430 | handler.get_function_subscription(topicArn, functionArn).then(result => { 431 | done.fail('expected error to be thrown but was: ' + result) 432 | }).catch(done) 433 | }) 434 | }) 435 | 436 | }) 437 | describe('when no subscriptions and no nextToken', () => { 438 | beforeEach(() => { 439 | let mockSnsSubscriptionsError = require('./test-data/snsListSubscriptions_response_error.json') 440 | AWSMock.mock("SNS", 'listSubscriptions', (params, callback) => { 441 | callback(null, mockSnsSubscriptionsError) 442 | }) 443 | }) 444 | 445 | afterEach(() => { 446 | AWSMock.restore("SNS", 'listSubscriptions') 447 | }) 448 | 449 | it('should throw Error', done => { 450 | let topicArn = 'not_found' 451 | let functionArn = 'not_found' 452 | handler.get_function_subscription(topicArn, functionArn).then(result => { 453 | done.fail('expected error to be thrown but was: ' + result) 454 | }).catch(done) 455 | }) 456 | }) 457 | }) 458 | -------------------------------------------------------------------------------- /plugin/spec/serverless_sns_filter.integration.ts: -------------------------------------------------------------------------------- 1 | import AWS= require('aws-sdk') 2 | import child = require('child_process') 3 | import { expect } from 'chai' 4 | import * as path from 'path' 5 | import * as addFilterPolicy from '../src/addFilterPolicy' 6 | 7 | let lambda: AWS.Lambda = new AWS.Lambda() 8 | 9 | const TIMEOUT_MS = 10 * 1000; 10 | 11 | let integrationTestProj = path.resolve(__dirname, '../../integration-test') 12 | 13 | describe('serverless-sns-filter plugin', () => { 14 | 15 | let runSls = (command: string) => { 16 | return child.execSync(command, { cwd: integrationTestProj }) 17 | } 18 | 19 | console.log(child.execSync('pwd', { cwd: integrationTestProj }).toString()) 20 | 21 | describe('when a function has SNS filter defined', () => { 22 | let functionWithFilter = 'hello' 23 | let functionWithFilterUsingArn = 'helloPreexisting' 24 | 25 | describe('when SNS message is published that matches the filter', () => { 26 | beforeAll(done => { 27 | let publishSnsWithAttribsFunction = 'sendMessage' 28 | let stdOut: any = runSls(`sls invoke -f ${publishSnsWithAttribsFunction}`) 29 | this.messageId = JSON.parse(stdOut).MessageId 30 | 31 | console.log(`sent message: ${this.messageId}`) 32 | 33 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000; 34 | 35 | done() 36 | }) 37 | 38 | it('should receive the message', done => { 39 | expect(this.messageId).not.to.be.undefined 40 | 41 | // it can take a period of time for the logs to come through 42 | setTimeout(() => { 43 | expect(runSls(`sls logs -f ${functionWithFilter}`).toString()).to.include(this.messageId) 44 | done() 45 | }, 10000) 46 | }) 47 | 48 | }) 49 | 50 | describe('when SNS message is published that does not match the filter', () => { 51 | beforeAll(done => { 52 | let publishNonMatchingSnsFunction = 'sendFilteredMessage' 53 | let stdOut: any = runSls(`sls invoke -f ${publishNonMatchingSnsFunction}`) 54 | this.messageId = JSON.parse(stdOut).MessageId 55 | 56 | console.log(`sent message: ${this.messageId}`) 57 | jasmine.DEFAULT_TIMEOUT_INTERVAL = 20000; 58 | done() 59 | }) 60 | 61 | it('should not receive the message', done => { 62 | expect(this.messageId).not.to.be.undefined 63 | 64 | // it can take a period of time for the logs to come through 65 | setTimeout(() => { 66 | expect(runSls(`sls logs -f ${functionWithFilter}`).toString()).not.to.include(this.messageId) 67 | done() 68 | }, 10000) 69 | }) 70 | }) 71 | 72 | it('should be applied to the subscription', done => { 73 | // TODO - cleanup 74 | 75 | let filterPolicy = require('../src/addFilterPolicy') 76 | 77 | let service='sls-plugin-it' 78 | let stage='dev' 79 | let preExistingTopic = 'prexisting-topic' 80 | let generatedTopic = `${service}-greeter-${stage}` 81 | 82 | AWS.config = new AWS.Config({ region: 'ap-southeast-2' }) 83 | 84 | let sns = new AWS.SNS() 85 | 86 | 87 | new AWS.Lambda().getFunctionConfiguration({ FunctionName: 'sls-plugin-it-dev-helloPreexisting' }).promise().then(result => { 88 | let functionArn = result.FunctionArn 89 | 90 | return sns.listTopics().promise().then(topics => { 91 | if(topics && topics.Topics){ 92 | let matchingTopic = (topic) => {return (topic.TopicArn && topic.TopicArn.includes(preExistingTopic))} 93 | let topicForFunction = topics.Topics.find(matchingTopic) 94 | if(topicForFunction){ 95 | 96 | console.log(`functionArn: ${functionArn}, topicArn: ${topicForFunction.TopicArn}`) 97 | return addFilterPolicy.get_function_subscription(topicForFunction.TopicArn, functionArn) 98 | 99 | } 100 | 101 | } 102 | 103 | throw new Error('Subscription not found') 104 | 105 | }) 106 | }).then((subscriptionArn:any) => { 107 | console.log('found subscription: ' + subscriptionArn) 108 | return sns.getSubscriptionAttributes({SubscriptionArn: subscriptionArn}).promise() 109 | }).then((subscriptionAttribs:AWS.SNS.GetSubscriptionAttributesResponse) => { 110 | let attribs: any = subscriptionAttribs.Attributes 111 | expect(attribs).contains.any.keys(['FilterPolicy']) 112 | expect(attribs.FilterPolicy).to.equal("{\"attrib_one\":[\"foo\",\"bar\"]}") 113 | done() 114 | }).then(done).catch(done.fail) 115 | 116 | }) 117 | 118 | }) 119 | 120 | 121 | }) 122 | -------------------------------------------------------------------------------- /plugin/spec/snsFilterPlugin.test.ts: -------------------------------------------------------------------------------- 1 | import child = require('child_process') 2 | import chai = require('chai') 3 | import sinonChai = require('sinon-chai') 4 | import * as sinon from 'sinon' 5 | import AWS = require('aws-sdk-mock') 6 | // import * as AWS from 'aws-sdk' 7 | import * as Serverless from 'serverless' 8 | import * as AwsProvider from 'serverless/lib/plugins/aws/provider/awsProvider' 9 | import { SnsFilterPlugin } from '../src/snsFilterPlugin'; 10 | import * as path from 'path'; 11 | import * as _ from 'lodash'; 12 | 13 | chai.use(sinonChai) 14 | const expect = chai.expect 15 | 16 | describe('serverless-sns-filter/snsFilterPlugin.ts', () => { 17 | 18 | let sampleCompiledCloudformationTemplate = { 19 | Resources: 20 | { 21 | HelloLambdaFunction: { 22 | Type: 'AWS::Lambda::Function', 23 | Properties: { 24 | FunctionName: 'serverless-sns-filter-integration-test-dev-hello', 25 | } 26 | }, 27 | SendMessageLambdaFunction: { 28 | Type: 'AWS::Lambda::Function', 29 | Properties: { 30 | FunctionName: 'serverless-sns-filter-integration-test-dev-sendMessage', 31 | } 32 | }, 33 | SNSTopicServerlesssnsfilterintegrationtestgreeterdev: 34 | { 35 | Type: 'AWS::SNS::Topic', 36 | Properties: 37 | { 38 | TopicName: 'serverless-sns-filter-integration-test-greeter-dev', 39 | DisplayName: '', 40 | Subscription: [{ 41 | Endpoint: { 42 | 'Fn::GetAtt': [ 43 | "HelloLambdaFunction", 44 | "Arn" 45 | ] 46 | }, Protocol: 'lambda' 47 | }] 48 | } 49 | }, 50 | SNSTopicSlspluginitanotherTopicdev: { 51 | "Type": "AWS::SNS::Topic", 52 | "Properties": { 53 | "TopicName": "serverless-sns-filter-integration-test-anotherTopic-dev", 54 | "DisplayName": "Another Topic (tm)", 55 | "Subscription": [ 56 | { 57 | "Endpoint": { 58 | "Fn::GetAtt": [ 59 | "HelloAnotherLambdaFunction", 60 | "Arn" 61 | ] 62 | }, 63 | "Protocol": "lambda" 64 | } 65 | ] 66 | } 67 | }, 68 | HelloPreexistingLambdaFunction: { 69 | Type: 'AWS::Lambda::Function', 70 | Properties: { 71 | FunctionName: 'sls-plugin-it-dev-helloPreexisting', 72 | }, 73 | }, 74 | HelloPreexisting2LambdaFunction: { 75 | Type: 'AWS::Lambda::Function', 76 | Properties: { 77 | FunctionName: 'sls-plugin-it-dev-helloPreexisting2', 78 | }, 79 | }, 80 | HelloPreexistingSnsSubscriptionPrexistingtopic: { 81 | Type: 'AWS::SNS::Subscription', 82 | Properties: { 83 | TopicArn: { 84 | 'Fn::Join': [ 85 | '', 86 | [ 87 | 'arn:aws:sns:ap-southeast-2:', 88 | { 89 | Ref: 'AWS::AccountId' 90 | }, 91 | ':prexisting-topic' 92 | ] 93 | ] 94 | }, 95 | Protocol: 'lambda', 96 | Endpoint: { 97 | 'Fn::GetAtt': [ 98 | 'HelloPreexistingLambdaFunction', 99 | 'Arn' 100 | ] 101 | } 102 | } 103 | } 104 | }, 105 | Outputs: { ServerlessDeploymentBucketName: { Value: { Ref: 'ServerlessDeploymentBucket' } } } 106 | }; 107 | 108 | let serverless; 109 | 110 | let sandbox; 111 | let awsProvider; 112 | 113 | let debug = false; 114 | // let debug = true; 115 | 116 | // let instance; 117 | 118 | 119 | beforeEach(() => { 120 | sandbox = sinon.sandbox.create(); 121 | serverless = new Serverless(); 122 | awsProvider = new AwsProvider(serverless, {}); 123 | awsProvider.sdk = AWS; 124 | serverless.init(); 125 | serverless.setProvider('aws', awsProvider); 126 | serverless.cli.log = function () { 127 | if (debug) { 128 | console.log.apply(this, arguments); 129 | } 130 | } 131 | 132 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate 133 | }); 134 | 135 | 136 | describe('#constructor()', () => { 137 | let instance; 138 | 139 | beforeEach(() => { 140 | instance = new SnsFilterPlugin(serverless, {}); 141 | 142 | }) 143 | 144 | it('should have a dependency on serverless', () => { 145 | expect(instance.serverless).to.equal(serverless) 146 | }) 147 | 148 | it('should initialise hook "after:aws:package:finalize:mergeCustomProviderResources"', () => { 149 | let hook = instance.hooks['after:aws:package:finalize:mergeCustomProviderResources']; 150 | expect(hook).not.to.be.undefined 151 | expect(hook).to.equal(instance.createDeploymentArtifacts) 152 | }) 153 | 154 | }); 155 | 156 | describe('#createDeploymentArtifacts()', () => { 157 | beforeEach(async done => { 158 | serverless.service.functions = { 159 | hello: 160 | { 161 | handler: 'handler.hello', 162 | events: 163 | [{ 164 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 165 | filter: { attrib_one: ['foo', 'bar'] } 166 | }], 167 | name: 'serverless-sns-filter-integration-test-dev-hello', 168 | } 169 | } 170 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate 171 | 172 | let instance = new SnsFilterPlugin(serverless, {}); 173 | 174 | await instance.createDeploymentArtifacts(); 175 | 176 | this.updatedCloudFormationResources = serverless.service.provider.compiledCloudFormationTemplate.Resources 177 | done() 178 | }) 179 | 180 | it('should call #createCustomResourcesForEachFunctionWithSnsFilterDefinition()', () => { 181 | expect(this.updatedCloudFormationResources).to.have.any.keys(['ApplyhelloFunctionFilterPolicy']) 182 | }) 183 | 184 | it('should merge the resources from #generateAddFilterPolicyLambdaResources()', () => { 185 | expect(this.updatedCloudFormationResources).to.have.any.keys('AddFilterPolicyLambdaFunction') 186 | }) 187 | }) 188 | 189 | describe('getLambdaFunctionCloudformationResourceKey()', () => { 190 | let resources = { 191 | HelloLambdaFunction: { 192 | Type: 'AWS::Lambda::Function', 193 | Properties: { 194 | FunctionName: 'serverless-sns-filter-integration-test-dev-hello', 195 | } 196 | }, 197 | SendMessageLambdaFunction: { 198 | Type: 'AWS::Lambda::Function', 199 | Properties: { 200 | FunctionName: 'serverless-sns-filter-integration-test-dev-sendMessage', 201 | } 202 | } 203 | } 204 | let instance; 205 | 206 | beforeEach(() => { 207 | instance = new SnsFilterPlugin(serverless, {}); 208 | }) 209 | it('should return the key for the matching LambdaFunction', () => { 210 | let result = instance.getLambdaFunctionCloudformationResourceKey('serverless-sns-filter-integration-test-dev-hello', resources) 211 | expect(result).to.equal('HelloLambdaFunction') 212 | }) 213 | it('should fail when no match found', () => { 214 | let call = () => instance.getLambdaFunctionCloudformationResourceKey('notFound', resources) 215 | expect(call).to.throw 216 | }) 217 | }) 218 | 219 | describe('#createCustomResourcesForEachFunctionWithSnsFilterDefinition()', () => { 220 | 221 | let instance; 222 | 223 | describe('when single filter defined', () => { 224 | beforeEach(async (done) => { 225 | 226 | serverless.service.functions = { 227 | hello: 228 | { 229 | handler: 'handler.hello', 230 | events: 231 | [{ 232 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 233 | filter: { attrib_one: ['foo', 'bar'] } 234 | }], 235 | name: 'serverless-sns-filter-integration-test-dev-hello', 236 | }, 237 | sendMessage: 238 | { 239 | handler: 'send.with_attribute', 240 | events: [], 241 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 242 | }, 243 | sendFilteredMessage: 244 | { 245 | handler: 'send.without_attribute', 246 | events: [], 247 | name: 'serverless-sns-filter-integration-test-dev-sendFilteredMessage', 248 | } 249 | }; 250 | 251 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate; 252 | 253 | instance = new SnsFilterPlugin(serverless, {}); 254 | 255 | await instance.createCustomResourcesForEachFunctionWithSnsFilterDefinition(); 256 | done() 257 | }) 258 | 259 | it('should create a Custom::ApplyFilterPolicy resource', () => { 260 | 261 | let cloudFormationResources = serverless.service.provider.compiledCloudFormationTemplate.Resources 262 | let customPolicies: Array = Object.keys(cloudFormationResources).map(key => cloudFormationResources[key].Type).filter(type => type === 'Custom::ApplyFilterPolicy') 263 | 264 | expect(customPolicies.length).to.equal(1) 265 | }) 266 | }) 267 | 268 | describe('when multiple filters defined', () => { 269 | let filterForType = (cloudFormationResources, type) => _.pickBy(cloudFormationResources, value => value.Type === type) 270 | let expectedCustomResourceKeys = ['ApplyhelloFunctionFilterPolicy', 'ApplysendMessageFunctionFilterPolicy'] 271 | 272 | beforeEach(async done => { 273 | let functionRef: ServerlessFunctionsAggregateDefinition = { 274 | hello: 275 | { 276 | handler: 'unimportant', 277 | events: 278 | [{ 279 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 280 | filter: { attrib_one: ['foo', 'bar'] } 281 | }], 282 | name: 'serverless-sns-filter-integration-test-dev-hello', 283 | }, 284 | sendMessage: 285 | { 286 | handler: 'unimportant', 287 | events: [{ 288 | sns: { 289 | topicName: 'serverless-sns-filter-integration-test-anotherTopic-dev', 290 | displayName: 'Another Topic (tm)' 291 | }, 292 | filter: { attrib_two: ['baz'] } 293 | }], 294 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 295 | }, 296 | } 297 | 298 | serverless.service.functions = functionRef; 299 | 300 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate; 301 | 302 | instance = new SnsFilterPlugin(serverless, {}); 303 | 304 | await instance.createCustomResourcesForEachFunctionWithSnsFilterDefinition(); 305 | 306 | this.updatedCloudFormationResources = serverless.service.provider.compiledCloudFormationTemplate.Resources 307 | this.customPolicies = filterForType(this.updatedCloudFormationResources, 'Custom::ApplyFilterPolicy') 308 | done() 309 | }) 310 | 311 | describe('generated Custom::ApplyFilterPolicy resources', () => { 312 | it('should create a Custom::ApplyFilterPolicy resource for each function with a filter_policy defined', () => { 313 | expect(Object.keys(this.customPolicies).length).to.equal(2) 314 | }) 315 | 316 | it('should generate the resource key based on the function key from serverless.yml', () => { 317 | expect(this.customPolicies).to.have.keys(expectedCustomResourceKeys) 318 | }) 319 | 320 | it('should generate ServiceToken, referencing the CustomResource Lambda', () => { 321 | let expectedServiceToken = { 322 | "Fn::GetAtt": "AddFilterPolicyLambdaFunction.Arn" 323 | } 324 | expectedCustomResourceKeys.forEach(key => { 325 | expect(this.customPolicies[key].Properties.ServiceToken).to.deep.equal(expectedServiceToken) 326 | }) 327 | }) 328 | 329 | it('should pass the region reference', () => { 330 | let expectedRegion = { Ref: "AWS::Region" } 331 | expectedCustomResourceKeys.forEach(key => { 332 | expect(this.customPolicies[key].Properties.Region).to.deep.equal(expectedRegion) 333 | }) 334 | }) 335 | 336 | it('should generate the sns_topicArn based on the topic for the function', () => { 337 | let expectedTopicArnsForResourceKeys = [ 338 | [ 339 | 'ApplyhelloFunctionFilterPolicy', { 340 | "Fn::Join": [ 341 | '', [ 342 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-greeter-dev" 343 | ] 344 | ] 345 | } 346 | ], 347 | [ 348 | 'ApplysendMessageFunctionFilterPolicy', 349 | { 350 | "Fn::Join": [ 351 | '', [ 352 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-anotherTopic-dev" 353 | ] 354 | ] 355 | } 356 | ], 357 | ] 358 | 359 | expectedTopicArnsForResourceKeys.forEach(tuple => { 360 | let key = tuple[0] as string 361 | expect(this.customPolicies[key].Properties.sns_topicArn).to.deep.equal(tuple[1]) 362 | }) 363 | }) 364 | 365 | }) 366 | 367 | 368 | }) 369 | 370 | }) 371 | 372 | describe('#customResourceForFn()', () => { 373 | let functionRef: ServerlessFunctionsAggregateDefinition = { 374 | hello: 375 | { 376 | handler: 'unimportant', 377 | events: 378 | [{ 379 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 380 | filter: { attrib_one: ['foo', 'bar'] } 381 | }], 382 | name: 'serverless-sns-filter-integration-test-dev-hello', 383 | }, 384 | sendMessage: 385 | { 386 | handler: 'unimportant', 387 | events: [{ 388 | sns: { 389 | topicName: 'serverless-sns-filter-integration-test-anotherTopic-dev', 390 | displayName: 'Another Topic (tm)' 391 | }, 392 | filter: { attrib_two: ['baz'] } 393 | }], 394 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 395 | }, 396 | } 397 | let instance: SnsFilterPlugin; 398 | beforeEach(() => { 399 | instance = new SnsFilterPlugin(serverless, {}); 400 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate; 401 | }) 402 | 403 | describe('when generating Custom::ApplyFilterPolicy resource for hello function', () => { 404 | 405 | beforeEach(() => { 406 | let key = 'hello' 407 | this.expectedKey = 'ApplyhelloFunctionFilterPolicy' 408 | this.result = instance.customResourceForFn(key, functionRef[key]) 409 | this.customResource = this.result[this.expectedKey] 410 | 411 | 412 | }) 413 | it('should generate the resource key based on the function key from serverless.yml', () => { 414 | expect(this.result).to.have.keys(this.expectedKey) 415 | }) 416 | 417 | it('should generate ServiceToken, referencing the CustomResource Lambda', () => { 418 | let expectedServiceToken = { 419 | "Fn::GetAtt": "AddFilterPolicyLambdaFunction.Arn" 420 | } 421 | expect(this.customResource.Properties.ServiceToken).to.deep.equal(expectedServiceToken) 422 | }) 423 | 424 | it('should pass the region reference', () => { 425 | let expectedRegion = { Ref: "AWS::Region" } 426 | expect(this.customResource.Properties.Region).to.deep.equal(expectedRegion) 427 | }) 428 | 429 | it('should generate the sns_topicArn based on the topic for the function', () => { 430 | let expectedTopicArnsForResourceKeys = [ 431 | [ 432 | 'ApplyhelloFunctionFilterPolicy', { 433 | "Fn::Join": [ 434 | '', [ 435 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-greeter-dev" 436 | ] 437 | ] 438 | } 439 | ], 440 | [ 441 | 'ApplysendMessageFunctionFilterPolicy', 442 | { 443 | "Fn::Join": [ 444 | '', [ 445 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-anotherTopic-dev" 446 | ] 447 | ] 448 | } 449 | ], 450 | ] 451 | 452 | let expectedTopicArn = { 453 | "Fn::Join": [ 454 | '', [ 455 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-greeter-dev" 456 | ] 457 | ] 458 | } 459 | 460 | expect(this.customResource.Properties.sns_topicArn).to.deep.equal(expectedTopicArn) 461 | }) 462 | 463 | it('should generate the functionArn based on the function key', () => { 464 | let expectedFunctionArn = { 465 | "Fn::Join": [ 466 | '', ['arn:aws:lambda:', { "Ref": "AWS::Region" }, ':', { "Ref": "AWS::AccountId" }, ':function:serverless-sns-filter-integration-test-dev-hello'] 467 | ] 468 | } 469 | expect(this.customResource.Properties.functionArn).to.deep.equal(expectedFunctionArn) 470 | }) 471 | 472 | it('should include the filter_policy from the function definition', () => { 473 | expect(this.customResource.Properties.filter_policy).to.equal('{"attrib_one":["foo","bar"]}') 474 | }) 475 | 476 | it('should depend on the function defintion', () => { 477 | expect(this.customResource.DependsOn).to.include('HelloLambdaFunction') 478 | }) 479 | 480 | it('should depend on the corresponding function and topic', () => { 481 | console.log(this.customResource.DependsOn) 482 | expect(this.customResource.DependsOn).to.include('SNSTopicServerlesssnsfilterintegrationtestgreeterdev') 483 | }) 484 | 485 | }) 486 | 487 | describe('when generating Custom::ApplyFilterPolicy resource for sendMessage function', () => { 488 | 489 | beforeEach(() => { 490 | let key = 'sendMessage' 491 | this.expectedKey = 'ApplysendMessageFunctionFilterPolicy' 492 | this.result = instance.customResourceForFn(key, functionRef[key]) 493 | this.customResource = this.result[this.expectedKey] 494 | 495 | 496 | }) 497 | it('should generate the resource key based on the function key from serverless.yml', () => { 498 | expect(this.result).to.have.keys(this.expectedKey) 499 | }) 500 | 501 | it('should generate ServiceToken, referencing the CustomResource Lambda', () => { 502 | let expectedServiceToken = { 503 | "Fn::GetAtt": "AddFilterPolicyLambdaFunction.Arn" 504 | } 505 | expect(this.customResource.Properties.ServiceToken).to.deep.equal(expectedServiceToken) 506 | }) 507 | 508 | it('should pass the region reference', () => { 509 | let expectedRegion = { Ref: "AWS::Region" } 510 | expect(this.customResource.Properties.Region).to.deep.equal(expectedRegion) 511 | }) 512 | 513 | it('should generate the sns_topicArn based on the topic for the function', () => { 514 | let expectedTopicArnsForResourceKeys = [ 515 | [ 516 | 'ApplyhelloFunctionFilterPolicy', { 517 | "Fn::Join": [ 518 | '', [ 519 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-greeter-dev" 520 | ] 521 | ] 522 | } 523 | ], 524 | [ 525 | 'ApplysendMessageFunctionFilterPolicy', 526 | { 527 | "Fn::Join": [ 528 | '', [ 529 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-anotherTopic-dev" 530 | ] 531 | ] 532 | } 533 | ], 534 | ] 535 | 536 | let expectedTopicArn = { 537 | "Fn::Join": [ 538 | '', [ 539 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, ":serverless-sns-filter-integration-test-anotherTopic-dev" 540 | ] 541 | ] 542 | } 543 | 544 | expect(this.customResource.Properties.sns_topicArn).to.deep.equal(expectedTopicArn) 545 | }) 546 | 547 | it('should generate the functionArn based on the function key', () => { 548 | let expectedFunctionArn = { 549 | "Fn::Join": [ 550 | '', ['arn:aws:lambda:', { "Ref": "AWS::Region" }, ':', { "Ref": "AWS::AccountId" }, ':function:serverless-sns-filter-integration-test-dev-sendMessage'] 551 | ] 552 | } 553 | expect(this.customResource.Properties.functionArn).to.deep.equal(expectedFunctionArn) 554 | }) 555 | 556 | it('should include the filter_policy from the function definition', () => { 557 | expect(this.customResource.Properties.filter_policy).to.equal('{"attrib_two":["baz"]}') 558 | }) 559 | 560 | }) 561 | 562 | }) 563 | 564 | describe('#functionsWithSnsFilters()', () => { 565 | 566 | let instance: SnsFilterPlugin; 567 | 568 | beforeEach(() => { 569 | instance = new SnsFilterPlugin(serverless, {}); 570 | 571 | }) 572 | 573 | it('should return an empty list when the function ref has no sns filters defined', () => { 574 | let functionRef: ServerlessFunctionsAggregateDefinition = { 575 | hello: 576 | { 577 | handler: 'handler.hello', 578 | events: 579 | [{ 580 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 581 | }], 582 | name: 'serverless-sns-filter-integration-test-dev-hello', 583 | }, 584 | sendMessage: 585 | { 586 | handler: 'send.with_attribute', 587 | events: [{ 588 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 589 | }], 590 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 591 | }, 592 | } 593 | 594 | let result = instance.functionsWithSnsFilters(functionRef) 595 | expect(result).to.be.instanceof(Array) 596 | expect(result).to.have.length(0) 597 | }) 598 | 599 | it('should return a function ref with sns filters defined', () => { 600 | let functionRef: ServerlessFunctionsAggregateDefinition = { 601 | hello: 602 | { 603 | handler: 'handler.hello', 604 | events: 605 | [{ 606 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 607 | filter: { attrib_one: ['foo', 'bar'] } 608 | }], 609 | name: 'serverless-sns-filter-integration-test-dev-hello', 610 | }, 611 | sendMessage: 612 | { 613 | handler: 'send.with_attribute', 614 | events: [{ 615 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 616 | }], 617 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 618 | }, 619 | } 620 | 621 | let result = instance.functionsWithSnsFilters(functionRef) 622 | expect(result).to.be.instanceof(Array) 623 | expect(result).to.have.length(1) 624 | expect(result[0]).to.deep.equal(['hello', functionRef.hello]) 625 | }) 626 | 627 | it('should return all function ref with sns filters defined', () => { 628 | let functionRef: ServerlessFunctionsAggregateDefinition = { 629 | hello: 630 | { 631 | handler: 'handler.hello', 632 | events: 633 | [{ 634 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 635 | filter: { attrib_one: ['foo', 'bar'] } 636 | }], 637 | name: 'serverless-sns-filter-integration-test-dev-hello', 638 | }, 639 | noFilter: 640 | { 641 | handler: 'send.with_attribute', 642 | events: [{ 643 | http: "GET hello", 644 | }], 645 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 646 | }, 647 | sendMessage: 648 | { 649 | handler: 'send.with_attribute', 650 | events: [{ 651 | sns: 'serverless-sns-filter-integration-test-greeter-dev', 652 | filter: { attrib_two: ['baz', 'boo'] } 653 | }], 654 | name: 'serverless-sns-filter-integration-test-dev-sendMessage', 655 | }, 656 | } 657 | 658 | let result = instance.functionsWithSnsFilters(functionRef) 659 | expect(result).to.be.instanceof(Array) 660 | expect(result).to.have.length(2) 661 | expect(result[0]).to.deep.equal(['hello', functionRef.hello]) 662 | expect(result[1]).to.deep.equal(['sendMessage', functionRef.sendMessage]) 663 | }) 664 | }) 665 | 666 | describe('#generateAddFilterPolicyLambdaResources()', () => { 667 | let instance: SnsFilterPlugin; 668 | 669 | beforeEach(async done => { 670 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate; 671 | let options = { 672 | stage: 'test' 673 | } 674 | instance = new SnsFilterPlugin(serverless, options); 675 | 676 | serverless.service.service = 'serviceName' 677 | this.generatedResources = await instance.generateAddFilterPolicyLambdaResources(); 678 | done(); 679 | }) 680 | 681 | it('should add the resources from resources.yml', () => { 682 | expect(this.generatedResources).to.have.any.keys(['AddFilterPolicyLambdaFunction', 'AddFilterPolicyLogGroup']) 683 | }) 684 | 685 | it('should generate AddFilterPolicyLambdaFunction name based on serverless project', () => { 686 | expect(this.generatedResources.AddFilterPolicyLambdaFunction.Properties.FunctionName).to.equal('serviceName-test-addFilterPolicy') 687 | }) 688 | 689 | it('should generate the log group based on the function name', () => { 690 | expect(this.generatedResources.AddFilterPolicyLogGroup.Properties.LogGroupName).to.equal('/aws/lambda/serviceName-test-addFilterPolicy') 691 | }) 692 | 693 | it('should generate the IamRoleAddFilterPolicyExecution policy name based on the service', () => { 694 | expect(this.generatedResources.IamRoleAddFilterPolicyExecution.Properties.Policies[0].PolicyName).to.equal('test-serviceName-addSnsFilterPolicyLambda') 695 | }) 696 | 697 | it('should generate the IamRoleAddFilterPolicyExecution role name based on the service', () => { 698 | expect(this.generatedResources.IamRoleAddFilterPolicyExecution.Properties.RoleName).to.equal('serviceName-test-slsSnsFilterRole') 699 | }) 700 | 701 | it('should limit IamRoleAddFilterPolicyExecution subscription modification only to the SNS topics that have filters applied') 702 | 703 | }) 704 | 705 | describe('when SNS topic referenced by arn', () => { 706 | let instance: SnsFilterPlugin; 707 | beforeEach(() => { 708 | instance = new SnsFilterPlugin(serverless, {}); 709 | serverless.service.provider.compiledCloudFormationTemplate = sampleCompiledCloudformationTemplate; 710 | }) 711 | 712 | let functionRef: ServerlessFunctionsAggregateDefinition = { 713 | "helloPreexisting": { 714 | "handler": "handler.hello", 715 | "events": [ 716 | { 717 | "sns": { 718 | "arn": { "Fn::Join": ["", ["arn:aws:sns:ap-southeast-2:", { "Ref": "AWS::AccountId" }, ":prexisting-topic"]] }, 719 | "topicName": "prexisting-topic", 720 | }, 721 | "filter": { "attrib_one": ["foo", "bar"] } 722 | } 723 | ], 724 | "name": "sls-plugin-it-dev-helloPreexisting", 725 | }, 726 | "helloPreexisting2": { 727 | "handler": "handler.hello", 728 | "events": [ 729 | { 730 | "sns": { 731 | "arn": "arn:aws:sns:ap-southeast-2:012345678901:prexisting-topic2", 732 | }, 733 | "filter": { "attrib_one": ["foo", "bar"] } 734 | } 735 | ], 736 | "name": "sls-plugin-it-dev-helloPreexisting2", 737 | }, 738 | 739 | } 740 | 741 | describe('#customResourceForFn()', () => { 742 | it('should reference the arn directly when using a simple string', () => { 743 | let key = 'helloPreexisting2' 744 | this.expectedKey = 'ApplyhelloPreexisting2FunctionFilterPolicy' 745 | this.result = instance.customResourceForFn(key, functionRef[key]) 746 | this.customResource = this.result[this.expectedKey] 747 | 748 | let expectedTopicArn = 'arn:aws:sns:ap-southeast-2:012345678901:prexisting-topic2' 749 | 750 | expect(this.customResource.Properties.sns_topicArn).to.deep.equal(expectedTopicArn) 751 | }) 752 | 753 | it('should reference the arn directly when using a complex arn', () => { 754 | let key = 'helloPreexisting' 755 | this.expectedKey = 'ApplyhelloPreexistingFunctionFilterPolicy' 756 | this.result = instance.customResourceForFn(key, functionRef[key]) 757 | this.customResource = this.result[this.expectedKey] 758 | let expectedTopicArn = { "Fn::Join": ["", ["arn:aws:sns:ap-southeast-2:", { "Ref": "AWS::AccountId" }, ":prexisting-topic"]] } 759 | 760 | expect(this.customResource.Properties.sns_topicArn).to.deep.equal(expectedTopicArn) 761 | 762 | }) 763 | 764 | it('should not add a dependency on the topic', () => { 765 | expect(this.customResource.DependsOn).not.to.include('SNSTopicServerlesssnsfilterintegrationtestgreeterdev') 766 | }) 767 | 768 | it('should add a dependency on the AWS::SNS::Subscription', () => { 769 | expect(this.customResource.DependsOn).to.include('HelloPreexistingSnsSubscriptionPrexistingtopic') 770 | }) 771 | }) 772 | 773 | }) 774 | 775 | describe('#getAllSubscriptionResourceKeys', () => { 776 | describe('When no AWS::Topic::Subscriptions resources defined', () => { 777 | let noSubscriptionResources = { 778 | HelloLambdaFunction: { 779 | Type: 'AWS::Lambda::Function', 780 | }, 781 | SNSTopicServerlesssnsfilterintegrationtestgreeterdev: { 782 | Type: 'AWS::SNS::Topic', 783 | }, 784 | }; 785 | 786 | let result: string[]; 787 | let instance: SnsFilterPlugin; 788 | beforeEach( () => { 789 | instance = new SnsFilterPlugin(serverless, {}); 790 | result = instance.getAllSubscriptionResourceKeys(noSubscriptionResources) 791 | }) 792 | 793 | it('should return []', () => { 794 | expect(result).to.be.an('array').that.has.length(0) 795 | }) 796 | 797 | }) 798 | 799 | describe('When a single AWS::Topic::Subscriptions resources defined', () => { 800 | let noSubscriptionResources = { 801 | HelloLambdaFunction: { 802 | Type: 'AWS::Lambda::Function', 803 | }, 804 | SNSTopicServerlesssnsfilterintegrationtestgreeterdev: { 805 | Type: 'AWS::SNS::Topic', 806 | }, 807 | HelloPreexistingSnsSubscriptionPrexistingtopic: { 808 | Type: 'AWS::SNS::Subscription', 809 | } 810 | }; 811 | 812 | let result: string[]; 813 | let instance: SnsFilterPlugin; 814 | beforeEach( () => { 815 | instance = new SnsFilterPlugin(serverless, {}); 816 | result = instance.getAllSubscriptionResourceKeys(noSubscriptionResources) 817 | }) 818 | 819 | it('should return the resource key', () => { 820 | expect(result).to.be.an('array').that.has.length(1) 821 | expect(result).to.include('HelloPreexistingSnsSubscriptionPrexistingtopic') 822 | }) 823 | 824 | }) 825 | 826 | describe('When multiple AWS::Topic::Subscriptions resources defined', () => { 827 | let noSubscriptionResources = { 828 | HelloLambdaFunction: { 829 | Type: 'AWS::Lambda::Function', 830 | }, 831 | SNSTopicServerlesssnsfilterintegrationtestgreeterdev: { 832 | Type: 'AWS::SNS::Topic', 833 | }, 834 | subscription1: { 835 | Type: 'AWS::SNS::Subscription', 836 | }, 837 | subscription2: { 838 | Type: 'AWS::SNS::Subscription', 839 | } 840 | }; 841 | 842 | let result: string[]; 843 | let instance: SnsFilterPlugin; 844 | beforeEach( () => { 845 | instance = new SnsFilterPlugin(serverless, {}); 846 | result = instance.getAllSubscriptionResourceKeys(noSubscriptionResources) 847 | }) 848 | 849 | it('should return the resource key', () => { 850 | expect(result).to.include.members(['subscription1','subscription2']) 851 | }) 852 | 853 | }) 854 | }) 855 | }) -------------------------------------------------------------------------------- /plugin/spec/support/jasmine.integration.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*.integration.ts" 5 | ], 6 | "helpers": [ 7 | "support/**/*.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /plugin/spec/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*.test.ts" 5 | ], 6 | "helpers": [ 7 | "support/**/*.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /plugin/spec/support/jasmine.wip.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*.wip.ts" 5 | ], 6 | "helpers": [ 7 | "support/**/*.ts" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /plugin/spec/test-data/filter-policy-request.json: -------------------------------------------------------------------------------- 1 | { 2 | "sns_subscription": "arn:aws:sns:us-east-1:613576916451:serverless-sns-filter-integration-test-greeter-dev:8dc916f8-8df8-4bb8-a2f7-0678859af7a0", 3 | "filter_policy": { 4 | "attrib_one": ["foo", "bar"] 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /plugin/spec/test-data/snsListSubscriptions_response_error.json: -------------------------------------------------------------------------------- 1 | { 2 | "ResponseMetadata": { 3 | "RequestId": "aaa40fe1-d8cf-5296-95cc-5d46072dd430" 4 | } 5 | } -------------------------------------------------------------------------------- /plugin/spec/test-data/snsListSubscriptions_response_p1.json: -------------------------------------------------------------------------------- 1 | { 2 | "ResponseMetadata": { 3 | "RequestId": "ce9a16a2-d7f6-597e-b240-44051000574a" 4 | }, 5 | "Subscriptions": [ 6 | { 7 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:DispatchUnicorn:7a964b8d-0b81-4fbc-83fb-c091eca4613a", 8 | "Owner": "012345678901", 9 | "Protocol": "lambda", 10 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:unicornservice-dev-UnicornsHandler", 11 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:DispatchUnicorn" 12 | }, 13 | { 14 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim:1e4b8a7a-cd8f-4b73-9d48-7dd25ebeeb1e", 15 | "Owner": "012345678901", 16 | "Protocol": "lambda", 17 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:slack-private-channel-migration-tim-event_log", 18 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim" 19 | }, 20 | { 21 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:testTopic:5f7e20d8-21f5-44c9-b9f6-a74cecb04aff", 22 | "Owner": "012345678901", 23 | "Protocol": "lambda", 24 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:test-function", 25 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:testTopic" 26 | }, 27 | { 28 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:CloudConformity:f235743d-1524-4010-9ff7-18a790c0c157", 29 | "Owner": "012345678901", 30 | "Protocol": "lambda", 31 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:auto-remediate-v1-AutoRemediateOrchestrator", 32 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:CloudConformity" 33 | }, 34 | { 35 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim:bce7b3c6-7741-4b2b-a78c-1583f7e52bc3", 36 | "Owner": "012345678901", 37 | "Protocol": "lambda", 38 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:slack-private-channel-migration-tim-create-channel-service", 39 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim" 40 | }, 41 | { 42 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim:e77034d5-94b3-4d19-8949-4ee7a8aa108f", 43 | "Owner": "012345678901", 44 | "Protocol": "lambda", 45 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:slack-private-channel-migration-tim-migrate-message-service", 46 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim" 47 | }, 48 | { 49 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:DispatchUnicorn:44164741-7a0e-4074-8678-bf505661e760", 50 | "Owner": "012345678901", 51 | "Protocol": "lambda", 52 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:unicornservice-dev-UnicornsHandler", 53 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:DispatchUnicorn" 54 | }, 55 | { 56 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim:4b1a0da5-796d-47f4-b32f-7e3c8c7a9945", 57 | "Owner": "012345678901", 58 | "Protocol": "lambda", 59 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:slack-private-channel-migration-tim-post-message-service", 60 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:rapids_tim" 61 | } 62 | ], 63 | "NextToken": "AAHJm4\/\/eGaqE5Jui+oa0yy8ycQV\/f8Cf8GYbWxFsGRUWw==" 64 | } 65 | -------------------------------------------------------------------------------- /plugin/spec/test-data/snsListSubscriptions_response_p2.json: -------------------------------------------------------------------------------- 1 | { 2 | "ResponseMetadata": { 3 | "RequestId": "aaa40fe1-d8cf-5296-95cc-5d46072dd430" 4 | }, 5 | "Subscriptions": [ 6 | { 7 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:p2_topic:365c86bd-f350-498a-9987-deba50e3685f", 8 | "Owner": "012345678901", 9 | "Protocol": "lambda", 10 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:p2_function", 11 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:p2_topic" 12 | } 13 | ], 14 | "NextToken": "page3" 15 | } -------------------------------------------------------------------------------- /plugin/spec/test-data/snsListSubscriptions_response_p3.json: -------------------------------------------------------------------------------- 1 | { 2 | "ResponseMetadata": { 3 | "RequestId": "aaa40fe1-d8cf-5296-95cc-5d46072dd430" 4 | }, 5 | "Subscriptions": [ 6 | { 7 | "SubscriptionArn": "arn:aws:sns:ap-southeast-2:012345678901:p3_topic:365c86bd-f350-498a-9987-deba50e3685f", 8 | "Owner": "012345678901", 9 | "Protocol": "lambda", 10 | "Endpoint": "arn:aws:lambda:ap-southeast-2:012345678901:function:p3_function", 11 | "TopicArn": "arn:aws:sns:ap-southeast-2:012345678901:p3_topic" 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /plugin/src/addFilterPolicy.js: -------------------------------------------------------------------------------- 1 | const response = require('cfn-response'); 2 | var AWS = require('aws-sdk'); 3 | 4 | const on_event = (event, context, callback) => { 5 | 6 | let subscriptionArn = event.sns_subscription 7 | console.log(`Setting Filter policy '${JSON.stringify(event.filter_policy)}' for subscription '${subscriptionArn}'`) 8 | const sns = new AWS.SNS() 9 | sns.setSubscriptionAttributes({ 10 | AttributeName: 'FilterPolicy', 11 | SubscriptionArn: subscriptionArn, 12 | AttributeValue: JSON.stringify(event.filter_policy) 13 | }).promise().then(result => { callback(null, result) }).catch(err => { callback(err) }) 14 | } 15 | 16 | const do_get_function_subscription = (sns, sns_topic, function_arn, nextPage) => { 17 | return sns.listSubscriptions({ NextToken: nextPage }).promise().then(response => { 18 | nextPage = response.NextToken 19 | if (response.Subscriptions) { 20 | var matchingSubscription = response.Subscriptions.filter(subscription => (subscription.TopicArn === sns_topic) && (subscription.Endpoint === function_arn))[0] 21 | if (matchingSubscription) { 22 | return matchingSubscription.SubscriptionArn 23 | } 24 | } 25 | 26 | if (nextPage) { 27 | return do_get_function_subscription(sns, sns_topic, function_arn, nextPage) 28 | } else { 29 | throw new Error(`no matching subscription for topic ${sns_topic} and function ${function_arn}`) 30 | } 31 | }) 32 | } 33 | 34 | function get_function_subscription(sns_topic, function_arn) { 35 | // TODO - refactor to construct SNS once 36 | const sns = new AWS.SNS() 37 | return new Promise((accept, reject) => { 38 | 39 | do_get_function_subscription(sns, sns_topic, function_arn).then(accept).catch(reject) 40 | 41 | }) 42 | } 43 | 44 | function custom_resource_event(event, context, callback) { 45 | 46 | console.log(JSON.stringify(event)) 47 | let physicalResouceId = context.logStreamName; 48 | 49 | // Delete events don't need to be supported - they shall be deleted when the subscription is deleted. 50 | // AWS currently does not support removing filters :( 51 | if (event.RequestType === 'Create' || event.RequestType === 'Update') { 52 | let filterRequestEvent; 53 | get_function_subscription(event.ResourceProperties.sns_topicArn, event.ResourceProperties.functionArn).then(subscription_arn => { 54 | filterRequestEvent = { 55 | sns_subscription: subscription_arn, 56 | filter_policy: JSON.parse(event.ResourceProperties.filter_policy) 57 | } 58 | 59 | on_event(filterRequestEvent, context, (error, result) => { 60 | if (error) { 61 | response.send(event, context, response.FAILED, { error: error }, physicalResouceId) 62 | 63 | callback(error) 64 | } else { 65 | response.send(event, context, response.SUCCESS, { result: result }, physicalResouceId) 66 | callback(null, "ok") 67 | } 68 | }) 69 | 70 | }).catch(err => { 71 | response.send(event, context, response.FAILED, { error: err }, physicalResouceId) 72 | callback(err) 73 | }) 74 | } else { 75 | response.send(event, context, response.SUCCESS, { result: 'delete not supported - To remove a SubscriptionFilter, delete the Topic Subscription' }, physicalResouceId) 76 | callback(null, "ok") 77 | } 78 | 79 | } 80 | 81 | module.exports = { 82 | custom_resource_event: custom_resource_event, 83 | get_function_subscription: get_function_subscription, 84 | on_event: on_event 85 | } 86 | -------------------------------------------------------------------------------- /plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | const SnsFilterPlugin = require('./snsFilterPlugin').SnsFilterPlugin 2 | 3 | module.exports = SnsFilterPlugin -------------------------------------------------------------------------------- /plugin/src/snsFilterPlugin.ts: -------------------------------------------------------------------------------- 1 | import { Serverless } from 'serverless' 2 | import * as path from 'path' 3 | import * as _ from 'lodash' 4 | import * as yamlParser from 'yamljs' 5 | 6 | 7 | export class SnsFilterPlugin { 8 | options: any; 9 | serverless: any; 10 | hooks: any; 11 | provider: any; 12 | functionRefs: any; 13 | 14 | constructor(serverless, options) { 15 | this.serverless = serverless; 16 | this.options = options 17 | this.hooks = { 18 | 'after:aws:package:finalize:mergeCustomProviderResources': this.createDeploymentArtifacts 19 | } 20 | 21 | this.provider = this.serverless.getProvider('AWS'); 22 | this.functionRefs = this.serverless.service.functions 23 | } 24 | 25 | getTopicCloudformationResourceKey = (topicName: string, resources: any): string => { 26 | let topic = (keypair) => keypair[1].Type === 'AWS::SNS::Topic' 27 | let matchingName = (keypair) => keypair[1].Properties.TopicName === topicName 28 | let keypair = _.toPairs(resources).filter(topic).find(matchingName) 29 | if (!keypair) { 30 | // should not happen 31 | throw new Error(`No matching AWS::SNS::Topic with name ${topicName} found`) 32 | } 33 | return keypair[0] 34 | } 35 | 36 | getLambdaFunctionCloudformationResourceKey = (functionName: string, resources: any): string => { 37 | let lambdaFunctions = (keypair) => keypair[1].Type === 'AWS::Lambda::Function' 38 | let matchingName = (keypair) => keypair[1].Properties.FunctionName === functionName 39 | let keypair = _.toPairs(resources).filter(lambdaFunctions).find(matchingName) 40 | if (!keypair) { 41 | // should not happen 42 | throw new Error(`No matching AWS::Lambda::Function with name ${functionName} found`) 43 | } 44 | return keypair[0] 45 | 46 | } 47 | 48 | getAllSubscriptionResourceKeys = (resources: any): string[] => { 49 | let subscription = (keypair) => keypair[1].Type === 'AWS::SNS::Subscription' 50 | let keys = _.toPairs(resources).filter(subscription).map((keypair)=> keypair[0]) 51 | return keys; 52 | } 53 | 54 | customResourceForFn = (functionKey: string, functionDef: ServerlessFunctionBody) => { 55 | let matchingSnsFilter = (event: ServerlessSnsEventDefinition | any) => (event.sns && event.filter); 56 | 57 | // Currently only support a single SNS filter 58 | let matchingSnsEvent = (functionDef.events.find(matchingSnsFilter) as ServerlessSnsEventDefinition) 59 | let filterPolicy = matchingSnsEvent.filter; 60 | let functionName = functionDef.name; 61 | let dependencies = ['AddFilterPolicyLambdaFunction', this.getLambdaFunctionCloudformationResourceKey(functionName, this.serverless.service.provider.compiledCloudFormationTemplate.Resources)] 62 | 63 | let snsTopicArn; 64 | if (matchingSnsEvent.sns.arn) { 65 | snsTopicArn = matchingSnsEvent.sns.arn 66 | let subscriptionRefs: string[] = this.getAllSubscriptionResourceKeys(this.serverless.service.provider.compiledCloudFormationTemplate.Resources) 67 | // subscriptionRefs.forEach(ref => ) 68 | dependencies.push(...subscriptionRefs) 69 | } else { 70 | let snsTopicName = matchingSnsEvent.sns.topicName ? matchingSnsEvent.sns.topicName : matchingSnsEvent.sns; 71 | snsTopicArn = { 72 | "Fn::Join": [ 73 | '', [ 74 | "arn:aws:sns:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, `:${snsTopicName}` 75 | ] 76 | ] 77 | } 78 | let topicRef = this.getTopicCloudformationResourceKey(snsTopicName, this.serverless.service.provider.compiledCloudFormationTemplate.Resources) 79 | dependencies.push(topicRef) 80 | } 81 | 82 | let applyFilterPolicyCustomResource = { 83 | Type: 'Custom::ApplyFilterPolicy', 84 | Properties: { 85 | ServiceToken: { 86 | "Fn::GetAtt": "AddFilterPolicyLambdaFunction.Arn" 87 | }, 88 | Region: { Ref: "AWS::Region" }, 89 | sns_topicArn: snsTopicArn, 90 | functionArn: { 91 | "Fn::Join": [ 92 | '', ["arn:aws:lambda:", { "Ref": "AWS::Region" }, ":", { "Ref": "AWS::AccountId" }, `:function:${functionName}`] 93 | ] 94 | }, 95 | filter_policy: JSON.stringify(filterPolicy), 96 | }, 97 | DependsOn: dependencies 98 | 99 | 100 | } 101 | let keyName = `Apply${functionKey}FunctionFilterPolicy` 102 | 103 | return { [keyName]: applyFilterPolicyCustomResource } 104 | } 105 | 106 | createCustomResourcesForEachFunctionWithSnsFilterDefinition = () => { 107 | let compiledCloudFormationTemplateResources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources 108 | this.functionsWithSnsFilters(this.functionRefs).forEach((keyToFnPair) => { 109 | let customResource = this.customResourceForFn(keyToFnPair[0], keyToFnPair[1]) 110 | _.merge(compiledCloudFormationTemplateResources, customResource) 111 | }) 112 | } 113 | 114 | createDeploymentArtifacts = async () => { 115 | let compiledCloudFormationTemplateResources = this.serverless.service.provider.compiledCloudFormationTemplate.Resources 116 | this.createCustomResourcesForEachFunctionWithSnsFilterDefinition() 117 | let resources = await this.generateAddFilterPolicyLambdaResources() 118 | 119 | _.merge(compiledCloudFormationTemplateResources, resources) 120 | } 121 | 122 | /* 123 | * Returns an array of pairs of ['functionKey', functionDefinition] that contain an SNS Filter. 124 | * Each functionKey refers to the reference in the serverless.yml 125 | * Matches the structure in the serverless.yml 126 | */ 127 | functionsWithSnsFilters = (functionsRef: ServerlessFunctionsAggregateDefinition) => { 128 | let matchingSnsFilter = (event: ServerlessSnsEventDefinition | any) => (event.sns && event.filter) 129 | let fnKeyToFnPairs = _.toPairs(functionsRef).filter(keypair => keypair[1].events.some(matchingSnsFilter)) 130 | return fnKeyToFnPairs; 131 | } 132 | 133 | getAddFilterPolicyLambdaFunctionName = () => { 134 | return `${this.serverless.service.service}-${this.options.stage}-addFilterPolicy` 135 | } 136 | 137 | updateAddFilterPolicyLambdaFunction = (addFilterPolicyLambdaFunction) => { 138 | let properties = addFilterPolicyLambdaFunction.Properties 139 | properties.FunctionName = this.getAddFilterPolicyLambdaFunctionName() 140 | } 141 | 142 | updateLogGroup = (logGroup) => { 143 | logGroup.Properties.LogGroupName = `/aws/lambda/${this.getAddFilterPolicyLambdaFunctionName()}` 144 | } 145 | 146 | updateIamRoleAddFilterPolicyExecution = (iamRole) => { 147 | iamRole.Properties.Policies[0].PolicyName = `${this.options.stage}-${this.serverless.service.service}-addSnsFilterPolicyLambda` 148 | iamRole.Properties.RoleName = `${this.serverless.service.service}-${this.options.stage}-slsSnsFilterRole` 149 | } 150 | 151 | async generateAddFilterPolicyLambdaResources(): Promise { 152 | let resources = yamlParser.load(path.resolve(__dirname, "..", 'resources.yml')).Resources 153 | this.updateAddFilterPolicyLambdaFunction(resources.AddFilterPolicyLambdaFunction) 154 | this.updateLogGroup(resources.AddFilterPolicyLogGroup) 155 | this.updateIamRoleAddFilterPolicyExecution(resources.IamRoleAddFilterPolicyExecution) 156 | return resources 157 | } 158 | } 159 | 160 | module.exports = { 161 | SnsFilterPlugin: SnsFilterPlugin 162 | } -------------------------------------------------------------------------------- /plugin/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "outDir": "../dist", 8 | "moduleResolution": "node", 9 | "lib": ["es2015", "es6", "esnext"], 10 | "rootDir": "./" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /plugin/src/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | interface ServerlessFunctionsAggregateDefinition { 2 | [functionName: string]: ServerlessFunctionBody 3 | } 4 | 5 | interface ServerlessFunctionBody { 6 | handler: string, 7 | events: Array, 8 | name: string 9 | } 10 | 11 | interface ServerlessEventDefinition { 12 | 13 | } 14 | interface ServerlessSnsEventDefinition extends ServerlessEventDefinition { 15 | sns: any, 16 | filter?: SnsFilterPluginFilterDefinition 17 | } 18 | 19 | interface SnsFilterPluginFilterDefinition { 20 | [attributeName: string]: Array 21 | } -------------------------------------------------------------------------------- /plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "preserveConstEnums": true, 4 | "strictNullChecks": true, 5 | "sourceMap": true, 6 | "target": "es5", 7 | "outDir": ".build", 8 | "moduleResolution": "node", 9 | "lib": ["es2015", "es6", "esnext"], 10 | "rootDir": "./" 11 | }, 12 | "include": [ 13 | "src", 14 | "spec" 15 | ], 16 | "exclude": [ 17 | "./node_modules" 18 | ] 19 | } -------------------------------------------------------------------------------- /plugin/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended"] 3 | } 4 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | --------------------------------------------------------------------------------