├── .gitignore ├── AUTHORS ├── docs └── Escalator.png ├── NOTICE ├── .github └── PULL_REQUEST_TEMPLATE.md ├── SES ├── savebody.json └── startsfn.json ├── LICENSE ├── src ├── checkack.py ├── incomingemail.py ├── sendpage.py └── registerpage.py ├── README.md ├── escalatorapi.yaml └── escalator-sam.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *-packaged.yaml 3 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jeff Strunk 2 | Ho Ming Li 3 | -------------------------------------------------------------------------------- /docs/Escalator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amazon-archives/aws-lambda-serverless-escalator/HEAD/docs/Escalator.png -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | AWS Lambda Serverless Escalator 2 | Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | *Issue #, if available:* 2 | 3 | *Description of changes:* 4 | 5 | 6 | By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. 7 | -------------------------------------------------------------------------------- /SES/savebody.json: -------------------------------------------------------------------------------- 1 | { 2 | "Actions": [ 3 | { 4 | "S3Action": { 5 | "BucketName": EscalatorBucket, 6 | "ObjectKeyPrefix": "incoming" 7 | } 8 | } 9 | ], 10 | "Enabled": true, 11 | "Recipients": [SESDomain], 12 | "TlsPolicy": "Optional", 13 | "ScanEnabled": false, 14 | "Name": "savebody" 15 | } 16 | -------------------------------------------------------------------------------- /SES/startsfn.json: -------------------------------------------------------------------------------- 1 | { 2 | "Name": "startsfn", 3 | "TlsPolicy": "Optional", 4 | "ScanEnabled": false, 5 | "Recipients": [SESDomain], 6 | "Enabled": true, 7 | "Actions": [ 8 | { 9 | "LambdaAction": { 10 | "InvocationType": "Event", 11 | "FunctionArn": IncomingEmailARN 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | software and associated documentation files (the "Software"), to deal in the Software 5 | without restriction, including without limitation the rights to use, copy, modify, 6 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | permit persons to whom the Software is furnished to do so. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | -------------------------------------------------------------------------------- /src/checkack.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import os 17 | import json 18 | import boto3 19 | import logging 20 | 21 | debug = os.environ.get('DEBUG', None) is not None 22 | logger = logging.getLogger(__name__) 23 | logger.addHandler(logging.StreamHandler()) 24 | logger.setLevel(logging.DEBUG if debug else logging.ERROR) 25 | 26 | # Global to improve load times 27 | dynamodb = boto3.resource('dynamodb') 28 | pages = dynamodb.Table(os.environ['DDB_PAGES_TABLE']) 29 | 30 | def checkack(pageid): 31 | response = pages.get_item(Key={'id': pageid}, 32 | ConsistentRead=True, 33 | ReturnConsumedCapacity='NONE') 34 | try: 35 | page = response['Item'] 36 | except KeyError as e: 37 | logger.error("no such page {}".format(pageid)) 38 | logger.debug("Exception: %s", e, exc_info=True) 39 | raise KeyError("no such page {}".format(pageid)) 40 | sys.exit(100) 41 | 42 | return page.get('ack', False) 43 | 44 | def handler(event, context): 45 | """event = { 46 | 'page': page object, 47 | 'team': team object 48 | }""" 49 | event['ack'] = checkack(event['page']['id']) 50 | return event 51 | -------------------------------------------------------------------------------- /src/incomingemail.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | """Find just the text body, discard the html and attachments""" 17 | 18 | import email.parser 19 | import email.policy 20 | import os 21 | import logging 22 | import json 23 | import boto3 24 | 25 | debug = os.environ.get('DEBUG', None) is not None 26 | logger = logging.getLogger() 27 | logger.setLevel(logging.DEBUG if debug else logging.INFO) 28 | 29 | # Global to improve load times 30 | s3 = boto3.client('s3') 31 | sfn = boto3.client('stepfunctions') 32 | 33 | def get_body(m): 34 | """extract the plain text body. return the body""" 35 | if m.is_multipart(): 36 | body = m.get_body(preferencelist=('plain',)).get_payload(decode=True) 37 | else: 38 | body = m.get_payload(decode=True) 39 | if isinstance(body, bytes): 40 | return body.decode() 41 | else: 42 | return body 43 | 44 | def incomingemail(mail, receipt): 45 | response = s3.get_object(Bucket=os.environ['BODY_BUCKET'], 46 | Key=os.path.join(os.environ.get('BODY_PREFIX',''), 47 | mail['messageId'])) 48 | 49 | m = email.parser.BytesParser(policy=email.policy.default).parsebytes(response['Body'].read()) 50 | output = { 51 | 'from': mail['commonHeaders']['from'][0], 52 | 'messageId': mail['messageId'], 53 | 'subject': mail['commonHeaders']['subject'], 54 | 'body': get_body(m) 55 | } 56 | 57 | for recipient in receipt['recipients']: 58 | output['email'] = recipient 59 | logger.info(json.dumps(output)) 60 | response = sfn.start_execution(stateMachineArn=os.environ['SFN_ARN'], 61 | name=recipient+output['messageId'], 62 | input=json.dumps(output)) 63 | 64 | def handler(event, context): 65 | incomingemail(event['Records'][0]['ses']['mail'], event['Records'][0]['ses']['receipt']) 66 | -------------------------------------------------------------------------------- /src/sendpage.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import os 17 | import sys 18 | import boto3 19 | import json 20 | import datetime 21 | import operator 22 | import itertools 23 | import logging 24 | 25 | debug = os.environ.get('DEBUG', None) is not None 26 | logger = logging.getLogger(__name__) 27 | logger.addHandler(logging.StreamHandler()) 28 | logger.setLevel(logging.DEBUG if debug else logging.ERROR) 29 | 30 | # Global to improve load times 31 | dynamodb = boto3.resource('dynamodb') 32 | pages = dynamodb.Table(os.environ['DDB_PAGES_TABLE']) 33 | ses = boto3.client('ses') 34 | 35 | def grouper(n, iterable): 36 | args = [iter(iterable)] * n 37 | return itertools.zip_longest(fillvalue=None,*args) 38 | 39 | def sendpage(page, team): 40 | stages = sorted(team['stages'], key=operator.itemgetter('order')) 41 | 42 | topage = [] 43 | for stage in stages: 44 | """iterate through stages in order until you see the first stage that hasn't been paged. 45 | Add each list of emails to the to list of recipients. Set newstage and delay in case 46 | last stage is reached to repeatedly page the last stage on a delay.""" 47 | topage.extend(stage['email']) 48 | newstage = stage['order'] 49 | delay = stage['delay'] 50 | if stage['order'] > page['stage']: 51 | break 52 | 53 | sent = [] 54 | for to in grouper(50, topage): 55 | response = ses.send_email( 56 | Source="no-reply@{}".format(os.environ.get('SES_DOMAIN', 57 | page['team'].split('@')[1])), 58 | Destination={'ToAddresses': [addr for addr in to if addr is not None]}, 59 | Message={ 60 | 'Subject': {'Data': page['subject']}, 61 | 'Body': {'Text': {'Data': page['body']}} 62 | }) 63 | sent.append(response['MessageId']) 64 | 65 | response = pages.update_item(Key={'id': page['id']}, 66 | UpdateExpression='SET stage = :order', 67 | ExpressionAttributeValues={ 68 | ':order': int(newstage) 69 | }) 70 | 71 | return sent, delay 72 | 73 | def handler(event, context): 74 | """event = { 75 | 'page': page object, 76 | 'team': team object 77 | }""" 78 | 79 | sent, delay = sendpage(event['page'], event['team']) 80 | 81 | event['sent'] = sent 82 | event['waitseconds'] = int(delay) 83 | return event 84 | -------------------------------------------------------------------------------- /src/registerpage.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | import os 17 | import sys 18 | import boto3 19 | import json 20 | import datetime 21 | import hashlib 22 | import logging 23 | 24 | debug = os.environ.get('DEBUG', None) is not None 25 | logger = logging.getLogger(__name__) 26 | logger.addHandler(logging.StreamHandler()) 27 | logger.setLevel(logging.DEBUG if debug else logging.ERROR) 28 | 29 | # Global to improve load times 30 | dynamodb = boto3.resource('dynamodb') 31 | pages = dynamodb.Table(os.environ['DDB_PAGES_TABLE']) 32 | teams = dynamodb.Table(os.environ['DDB_TEAMS_TABLE']) 33 | 34 | def registerpage(sender, email, subject, body, messageId): 35 | response = teams.get_item(Key={'email': email}, 36 | ReturnConsumedCapacity='NONE') 37 | try: 38 | team = response['Item'] 39 | except KeyError as e: 40 | logger.error("no such team {}".format(email)) 41 | logger.debug("Exception: %s", e, exc_info=True) 42 | raise KeyError("no such team {}".format(email)) 43 | sys.exit(100) 44 | 45 | page = { 46 | 'timestamp': int(datetime.datetime.timestamp(datetime.datetime.now())), 47 | 'team': team['email'], 48 | 'ack': False, 49 | 'ttl': int(datetime.datetime.timestamp(datetime.datetime.now() + datetime.timedelta(days=7))), 50 | 'stage': -1 51 | } 52 | 53 | checksum = hashlib.sha256() 54 | checksum.update(bytes((page['team'] + subject + messageId + body)[0:4096], 'utf-8')) 55 | page['id'] = checksum.hexdigest() 56 | 57 | page['subject'] = subject 58 | page['body'] = "Ack page: {0}/{1}\n\n".format(os.environ['ACK_API_URL'], page['id']) \ 59 | + "From: {}\n\n".format(sender) \ 60 | + body 61 | 62 | # Step Function invocation can fail quietly on ConditionalCheckFailedException due to duplicate page 63 | pages.put_item(Item=page, 64 | ConditionExpression=boto3.dynamodb.conditions.Attr('id').not_exists()) 65 | return page, team 66 | 67 | def handler(event, context): 68 | """event = { 69 | 'from': sender, 70 | 'email': recipient, 71 | 'subject': subject, 72 | 'body': body, 73 | 'messageId': messageId 74 | }""" 75 | page, team = registerpage(event['from'], event['email'], event['subject'], event['body'], 76 | event.get('messageId', context.aws_request_id)) 77 | return {'page': page, 'team': team} 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | serverless-escalator is a page escalation tool using [AWS SAM](https://github.com/awslabs/serverless-application-model) 2 | 3 | # Usage 4 | ## Send a Page 5 | You can send a page to serverless-escalator via email or API. For email, simply send a plain text email to the team email address(other parts will be stripped e.g. html and images). 6 | 7 | NOTE: The only information needed to send a page is the team email address. The body and subject are stored unencrypted in your account, so this should only be used with non-sensitive data. 8 | 9 | The API endpoint includes an unauthenticated `page` POST action. The body is a JSON object with the following schema: 10 | ``` 11 | { 12 | "from": "sender email address", 13 | "email": "team email address", 14 | "subject": "page subject", 15 | "body": "page body" 16 | } 17 | ``` 18 | 19 | ## Create a team 20 | The API endpoint includes a `registerteam` POST action that can be accessed with IAM authentication. 21 | The body is a JSON object following the [teams table schema](#teams). 22 | 23 | ``` team.json 24 | { 25 | "email": "testteam@SESDomain", 26 | "stages": [ 27 | { 28 | "delay": 600, 29 | "email": [ 30 | "tier1@example.com" 31 | ], 32 | "order": 10 33 | }, 34 | { 35 | "delay": 600, 36 | "email": [ 37 | "tier2@example.com" 38 | ], 39 | "order": 20 40 | } 41 | ] 42 | } 43 | ``` 44 | 45 | Using [awscurl](https://github.com/okigan/awscurl), you can send signed requests to create teams. 46 | ``` 47 | $ awscurl --profile myawscliprofile https://$DOMAIN/registerteam -X POST -d @/tmp/team.json 48 | ``` 49 | 50 | # Setup 51 | Set up or make sure you have the following available. 52 | 53 | ## Route53 Zone 54 | 55 | Set up a public hosted zone in Route53. This domain will be used for SES sending and receiving and 56 | API Gateway. The Hosted Zone ID will be used as input for the CloudFormation Stack. 57 | 58 | ``` 59 | $ aws route53 create-hosted-zone --name fqdn.example.com --caller-reference `date +%s` 60 | { 61 | "HostedZone": { 62 | "Id": "/hostedzone/ZZZZZZZZZZZZZZ", 63 | "Name": "fqdn.example.com.", 64 | "ResourceRecordSetCount": 2, 65 | "CallerReference": "1511368437", 66 | "Config": { 67 | "PrivateZone": false 68 | } 69 | }, 70 | "ChangeInfo": { 71 | "Id": "/change/CAMZCCCCCCCCC", 72 | "SubmittedAt": "2017-11-22T16:33:56.185Z", 73 | "Status": "PENDING" 74 | }, 75 | "DelegationSet": { 76 | "NameServers": [ 77 | "ns-nnn.awsdns-15.net", 78 | "ns-nnnn.awsdns-60.co.uk", 79 | "ns-nnn.awsdns-53.com", 80 | "ns-nnnn.awsdns-00.org" 81 | ] 82 | }, 83 | "Location": "https://route53.amazonaws.com/2013-04-01/hostedzone/ZZZZZZZZZZZZZZ" 84 | } 85 | ``` 86 | 87 | ## ACM Certificate 88 | Until CloudFormation supports regional endpoints for API Gateway, you will need to create your 89 | certificates in us-east-1. The ARN will be used as input for the CloudFormation Stack. 90 | 91 | Use the Route53 Console to [create a certificate using DNS 92 | validation](http://docs.aws.amazon.com/acm/latest/userguide/gs-acm-validate-dns.html). Be sure that 93 | you are in the us-east-1 region. 94 | 95 | ## Artifact Bucket 96 | The AWS SAM needs an S3 bucket to store artifacts such as Lambda functions and Swagger files. Create 97 | a bucket in the region you choose to run in. 98 | 99 | `aws s3api create-bucket --bucket artifactbucket` 100 | 101 | ## Deploy CloudFormation Stack 102 | The CloudFormation Stack uses the [AWS Serverless Application 103 | Model](https://github.com/awslabs/serverless-application-model). 104 | 105 | `$BUCKET` below is the artifact bucket created in the previous step. 106 | 107 | Stack Parameters: 108 | Domain: Domain for the API endpoint 109 | DomainCertArn: ACM certificate ARN for Domain in us-east-1 110 | Env: API deployment stage name 111 | SESDomain: Domain for sending email 112 | EscalatorAPIURI: S3 URI for escalator api swagger 113 | Route53Zone: Route53 hosted zone ID 114 | 115 | ``` 116 | $ aws cloudformation package --template-file escalator-sam.yaml --s3-bucket $BUCKET \ 117 | --s3-prefix escalator --output-template-file escalator-packaged.yaml 118 | $ aws s3 cp escalatorapi.yaml s3://$BUCKET/escalator/escalatorapi.yaml 119 | $ aws cloudformation deploy --template-file escalator-packaged.yaml \ 120 | --stack-name serverless-escalator \ 121 | --capabilities CAPABILITY_IAM \ 122 | --parameter-overrides Domain=$DOMAIN Env=Prod SESDomain=$DOMAIN \ 123 | EscalatorAPIURI=s3://$BUCKET/escalator/escalatorapi.yaml \ 124 | Route53Zone=$ROUTE53ZONEID DomainCertArn=$CERTIFICATEARN 125 | ``` 126 | 127 | ## SES Receive Ruleset 128 | SES Receiving rules can't be managed by CloudFormation. The outputs of the stack contain the 129 | `IncomingEmailARN` and `EscalatorBucket` values needed for the rules. Replace the placeholders in 130 | `SES/savebody.json` and `SES/startsfn.json`. The rules will apply to all addresses in `SESDomain`. 131 | 132 | ``` 133 | $ aws ses create-receipt-rule-set --rule-set-name serverless-escalator 134 | $ aws ses create-receipt-rule --rule-set-name serverless-escalator --rule file://SES/savebody.json 135 | $ aws ses create-receipt-rule --rule-set-name --after savebody --rule file://SES/startsfn.json 136 | ``` 137 | 138 | # Components 139 | ![architecture diagram](docs/Escalator.png) 140 | 141 | ## incoming_email 142 | Lambda function triggered by SES 143 | 144 | 1. invoke step function 145 | 146 | ## registerpage 147 | Lambda function triggered by Step Functions 148 | 149 | 1. Lookup team by email in DynamoDB `teams` table 150 | 2. Register page in DynamoDB `pages` table 151 | 3. Append ack URL to email body 152 | 4. Call `sendpage` 153 | 154 | ## checkack 155 | Lambda function invoked by step functions 156 | 157 | 1. check if the page was acked before proceeding to the send page state or finishing 158 | 159 | ## sendpage 160 | Lambda function invoked by step functions 161 | 162 | 1. Send email to appropriate stages 163 | 2. Schedule next invocation of `sendpage` according to next stage's delay 164 | 165 | ## pages 166 | DynamoDB table to track pages 167 | 168 | Schema: 169 | ``` 170 | { 171 | id: page id, 172 | timestamp: creation time, 173 | ack: False or timestamp of ack 174 | team: email of team in `teams` table, 175 | stage: `order` of last stage called 176 | } 177 | ``` 178 | 179 | ## teams 180 | DynamoDB to configure teams 181 | 182 | Schema: 183 | ``` 184 | { 185 | email: email that pages get sent to, 186 | stages: [ 187 | { 188 | order: integer with priority for stage. lower numbers are paged first, 189 | email: [email addresses to send pages for stage], 190 | delay: seconds to wait before sending to the next stage 191 | } 192 | ] 193 | } 194 | ``` 195 | -------------------------------------------------------------------------------- /escalatorapi.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | --- 17 | swagger: "2.0" 18 | info: 19 | version: "2017-11-07T23:47:56Z" 20 | title: 21 | Fn::Sub: ${AWS::StackName} 22 | host: "$stageVariables.domain" 23 | schemes: 24 | - "https" 25 | paths: 26 | /ack/{id}: 27 | get: 28 | consumes: 29 | - "application/json" 30 | produces: 31 | - "application/json" 32 | parameters: 33 | - name: "id" 34 | in: "path" 35 | required: true 36 | type: "string" 37 | responses: 38 | 200: 39 | description: "200 response" 40 | schema: 41 | $ref: "#/definitions/Empty" 42 | 400: 43 | description: "400 response" 44 | schema: 45 | $ref: "#/definitions/Error" 46 | 500: 47 | description: "500 response" 48 | schema: 49 | $ref: "#/definitions/Error" 50 | x-amazon-apigateway-integration: 51 | credentials: 52 | Fn::Sub: ${EscalatorAPIRole.Arn} 53 | responses: 54 | 4\d{2}: 55 | statusCode: "400" 56 | responseTemplates: 57 | application/json: "{\n \"message\"\ 58 | \ : \"error\"\n}" 59 | 5\d{2}: 60 | statusCode: "500" 61 | responseTemplates: 62 | application/json: "{\n \"message\"\ 63 | \ : \"error\"\n}" 64 | 2\d{2}: 65 | statusCode: "200" 66 | responseTemplates: 67 | application/json: "{\n \"message\"\ 68 | \ : \"success\"\n}" 69 | requestTemplates: 70 | application/json: | 71 | { 72 | "Key": { 73 | "id": { 74 | "S": "$input.params('id')" 75 | } 76 | }, 77 | "TableName": "$stageVariables.pagestable", 78 | "UpdateExpression": "SET ack = :ack", 79 | "ExpressionAttributeValues": { 80 | ":ack": { "BOOL": true } 81 | }, 82 | "ConditionExpression": "attribute_exists(id)", 83 | "ReturnValues": "NONE" 84 | } 85 | uri: "arn:aws:apigateway:us-west-2:dynamodb:action/UpdateItem" 86 | passthroughBehavior: "when_no_templates" 87 | httpMethod: "POST" 88 | type: "aws" 89 | /page: 90 | post: 91 | consumes: 92 | - "application/json" 93 | produces: 94 | - "application/json" 95 | parameters: 96 | - in: "body" 97 | name: "Page" 98 | required: true 99 | schema: 100 | $ref: "#/definitions/Page" 101 | responses: 102 | 200: 103 | description: "200 response" 104 | schema: 105 | $ref: "#/definitions/Empty" 106 | 400: 107 | description: "400 response" 108 | schema: 109 | $ref: "#/definitions/Error" 110 | 500: 111 | description: "500 response" 112 | schema: 113 | $ref: "#/definitions/Error" 114 | x-amazon-apigateway-request-validator: "Validate body" 115 | x-amazon-apigateway-integration: 116 | credentials: 117 | Fn::Sub: ${EscalatorAPIRole.Arn} 118 | responses: 119 | 4\d{2}: 120 | statusCode: "400" 121 | responseTemplates: 122 | application/json: "{\n \"message\"\ 123 | \ : \"error\"\n}" 124 | 5\d{2}: 125 | statusCode: "500" 126 | responseTemplates: 127 | application/json: "{\n \"message\"\ 128 | \ : \"error\"\n}" 129 | 2\d{2}: 130 | statusCode: "200" 131 | responseTemplates: 132 | application/json: "{\n \"message\"\ 133 | \ : \"success\"\n}" 134 | requestTemplates: 135 | application/json: | 136 | { 137 | "stateMachineArn": "$util.escapeJavaScript($stageVariables.sfnescalator)", 138 | "name": "$context.requestId", 139 | "input": "$util.escapeJavaScript($input.json('$'))" 140 | } 141 | uri: "arn:aws:apigateway:us-west-2:states:action/StartExecution" 142 | passthroughBehavior: "when_no_templates" 143 | httpMethod: "POST" 144 | type: "aws" 145 | /registerteam: 146 | post: 147 | consumes: 148 | - "application/json" 149 | produces: 150 | - "application/json" 151 | parameters: 152 | - in: "body" 153 | name: "Team" 154 | required: true 155 | schema: 156 | $ref: "#/definitions/Team" 157 | responses: 158 | 200: 159 | description: "200 response" 160 | schema: 161 | $ref: "#/definitions/Empty" 162 | 400: 163 | description: "400 response" 164 | schema: 165 | $ref: "#/definitions/Error" 166 | 500: 167 | description: "500 response" 168 | schema: 169 | $ref: "#/definitions/Error" 170 | security: 171 | - sigv4: [] 172 | x-amazon-apigateway-request-validator: "Validate body" 173 | x-amazon-apigateway-integration: 174 | credentials: 175 | Fn::Sub: ${EscalatorAPIRole.Arn} 176 | responses: 177 | 4\d{2}: 178 | statusCode: "400" 179 | responseTemplates: 180 | application/json: "{\n \"message\"\ 181 | \ : \"error\"\n}" 182 | 5\d{2}: 183 | statusCode: "500" 184 | responseTemplates: 185 | application/json: "{\n \"message\"\ 186 | \ : \"error\"\n}" 187 | 2\d{2}: 188 | statusCode: "200" 189 | responseTemplates: 190 | application/json: "{\n \"message\"\ 191 | \ : \"success\"\n}" 192 | requestTemplates: 193 | application/json: | 194 | #set($inputroot = $input.path('$')) 195 | { 196 | "TableName": "$stageVariables.teamstable", 197 | "ReturnValues": "NONE", 198 | "Item": { 199 | "email": { 200 | "S": "$inputroot.email" 201 | }, 202 | "stages": { 203 | "L": [ 204 | #foreach( $stage in $inputroot.stages ) 205 | { 206 | "M": { 207 | "delay": { 208 | "N": "$stage.delay" 209 | }, 210 | "email": { 211 | "L": [ 212 | #foreach( $email in $stage.email ) 213 | { 214 | "S": "$email" 215 | }#if($foreach.hasNext),#end 216 | #end 217 | ] 218 | }, 219 | "order": { 220 | "N": "$stage.order" 221 | } 222 | } 223 | }#if($foreach.hasNext),#end 224 | #end 225 | ] 226 | } 227 | } 228 | } 229 | uri: "arn:aws:apigateway:us-west-2:dynamodb:action/PutItem" 230 | passthroughBehavior: "when_no_templates" 231 | httpMethod: "POST" 232 | type: "aws" 233 | definitions: 234 | Empty: 235 | type: "object" 236 | title: "Empty Schema" 237 | Error: 238 | type: "object" 239 | properties: 240 | message: 241 | type: "string" 242 | title: "Error Schema" 243 | Team: 244 | type: "object" 245 | description: "representation of a team object" 246 | required: 247 | - "email" 248 | - "stages" 249 | properties: 250 | email: 251 | type: "string" 252 | description: "team identifier" 253 | stages: 254 | type: "array" 255 | items: 256 | type: "object" 257 | properties: 258 | email: 259 | type: "array" 260 | items: 261 | type: "string" 262 | description: "email address" 263 | delay: 264 | type: "integer" 265 | description: "seconds to wait before escalating to the next stage" 266 | order: 267 | type: "integer" 268 | description: "integer with priority for stage. lower numbers are paged first" 269 | required: 270 | - "email" 271 | - "delay" 272 | - "order" 273 | Page: 274 | type: "object" 275 | required: 276 | - "body" 277 | - "email" 278 | - "from" 279 | - "subject" 280 | properties: 281 | from: 282 | type: "string" 283 | description: "email of sender" 284 | email: 285 | type: "string" 286 | description: "email of team to notify" 287 | subject: 288 | type: "string" 289 | description: "subject of notification" 290 | body: 291 | type: "string" 292 | description: "content of notification" 293 | description: "A representation of a page notification" 294 | x-amazon-apigateway-request-validators: 295 | Validate body: 296 | validateRequestParameters: false 297 | validateRequestBody: true 298 | securityDefinitions: 299 | sigv4: 300 | type: "apiKey" 301 | name: "Authorization" 302 | in: "header" 303 | x-amazon-apigateway-authtype: "awsSigv4" 304 | -------------------------------------------------------------------------------- /escalator-sam.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2017-2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy of this 4 | # software and associated documentation files (the "Software"), to deal in the Software 5 | # without restriction, including without limitation the rights to use, copy, modify, 6 | # merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 7 | # permit persons to whom the Software is furnished to do so. 8 | # 9 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 10 | # INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 11 | # PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 12 | # HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 13 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 14 | # SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 15 | 16 | AWSTemplateFormatVersion: '2010-09-09' 17 | Transform: 'AWS::Serverless-2016-10-31' 18 | Description: ESCALATOR for OnCall Escalation 19 | Parameters: 20 | Domain: 21 | Type: String 22 | Description: Domain for the API endpoint 23 | DomainCertArn: 24 | Type: String 25 | Description: ACM certificate ARN for Domain in us-east-1 26 | Env: 27 | Type: String 28 | Description: API deployment stage name 29 | SESDomain: 30 | Type: String 31 | Description: Domain for sending email 32 | EscalatorAPIURI: 33 | Type: String 34 | Description: S3 URI for escalator api swagger 35 | Route53Zone: 36 | Type: String 37 | Description: Route53 hosted zone ID 38 | 39 | Conditions: 40 | HasDomain: !Not [ !Equals [ !Ref Domain, "" ]] 41 | 42 | Resources: 43 | EscalatorBucket: 44 | Type: "AWS::S3::Bucket" 45 | Properties: 46 | LifecycleConfiguration: 47 | Rules: 48 | - ExpirationInDays: 7 49 | Status: "Enabled" 50 | EscalatorBucketPolicy: 51 | Type: AWS::S3::BucketPolicy 52 | Properties: 53 | Bucket: !Ref EscalatorBucket 54 | PolicyDocument: 55 | Statement: 56 | - 57 | Action: s3:PutObject 58 | Resource: !Sub 'arn:aws:s3:::${EscalatorBucket}/*' 59 | Effect: Allow 60 | Principal: 61 | Service: ses.amazonaws.com 62 | Condition: 63 | StringEquals: 64 | aws:Referer: !Ref AWS::AccountId 65 | EscalatorStateMachineRole: 66 | Type: "AWS::IAM::Role" 67 | Properties: 68 | AssumeRolePolicyDocument: 69 | Version: "2012-10-17" 70 | Statement: 71 | - 72 | Effect: "Allow" 73 | Principal: 74 | Service: !Join [ ".", [ "states", !Ref 'AWS::Region', "amazonaws.com" ] ] 75 | Action: 76 | - "sts:AssumeRole" 77 | Policies: 78 | - 79 | PolicyName: "invokelambda" 80 | PolicyDocument: 81 | Version: "2012-10-17" 82 | Statement: 83 | - 84 | Effect: "Allow" 85 | Action: "lambda:InvokeFunction" 86 | Resource: 87 | - !GetAtt RegisterPage.Arn 88 | - !GetAtt SendPage.Arn 89 | - !GetAtt CheckAck.Arn 90 | EscalatorAPIRole: 91 | Type: "AWS::IAM::Role" 92 | Properties: 93 | AssumeRolePolicyDocument: 94 | Version: "2012-10-17" 95 | Statement: 96 | - 97 | Effect: "allow" 98 | Principal: 99 | Service: "apigateway.amazonaws.com" 100 | Action: 101 | - "sts:AssumeRole" 102 | ManagedPolicyArns: 103 | - "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" 104 | Policies: 105 | - 106 | PolicyName: "invokesfn" 107 | PolicyDocument: 108 | Version: '2012-10-17' 109 | Statement: 110 | - Action: 'states:StartExecution' 111 | Effect: Allow 112 | Resource: 113 | - !Ref EscalatorStateMachine 114 | - 115 | PolicyName: "ackpage" 116 | PolicyDocument: 117 | Version: '2012-10-17' 118 | Statement: 119 | - Action: 120 | - 'dynamodb:UpdateItem' 121 | Condition: 122 | ForAllValues:StringEquals: 123 | dynamodb:Attributes: 124 | - ack 125 | - id 126 | StringEqualsIfExists: 127 | dynamodb:ReturnValues: 128 | - NONE 129 | Effect: Allow 130 | Resource: 131 | - !GetAtt PagesTable.Arn 132 | - 133 | PolicyName: "registerteam" 134 | PolicyDocument: 135 | Version: '2012-10-17' 136 | Statement: 137 | - Action: 138 | - 'dynamodb:PutItem' 139 | Effect: Allow 140 | Resource: 141 | - !GetAtt TeamsTable.Arn 142 | EscalatorLambdaRole: 143 | Type: "AWS::IAM::Role" 144 | Properties: 145 | AssumeRolePolicyDocument: 146 | Version: "2012-10-17" 147 | Statement: 148 | - 149 | Effect: "allow" 150 | Principal: 151 | Service: "lambda.amazonaws.com" 152 | Action: 153 | - "sts:AssumeRole" 154 | ManagedPolicyArns: 155 | - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 156 | Policies: 157 | - 158 | PolicyName: "accessresources" 159 | PolicyDocument: 160 | Version: '2012-10-17' 161 | Statement: 162 | - Action: 163 | - 'ses:SendEmail' 164 | - 'ses:SendRawEmail' 165 | Effect: Allow 166 | Resource: 167 | - '*' 168 | - Action: 169 | - 'dynamodb:GetItem' 170 | - 'dynamodb:PutItem' 171 | - 'dynamodb:UpdateItem' 172 | Effect: Allow 173 | Resource: 174 | - !GetAtt PagesTable.Arn 175 | - Action: 176 | - 'dynamodb:GetItem' 177 | Effect: Allow 178 | Resource: 179 | - !GetAtt TeamsTable.Arn 180 | IncomingEmailRole: 181 | Type: "AWS::IAM::Role" 182 | Properties: 183 | AssumeRolePolicyDocument: 184 | Version: "2012-10-17" 185 | Statement: 186 | - 187 | Effect: "allow" 188 | Principal: 189 | Service: "lambda.amazonaws.com" 190 | Action: 191 | - "sts:AssumeRole" 192 | ManagedPolicyArns: 193 | - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 194 | Policies: 195 | - 196 | PolicyName: "accessresources" 197 | PolicyDocument: 198 | Version: '2012-10-17' 199 | Statement: 200 | - Action: 201 | - 's3:GetBucketLocation' 202 | - 's3:GetObject' 203 | Effect: Allow 204 | Resource: 205 | - !GetAtt EscalatorBucket.Arn 206 | - !Join 207 | - '/' 208 | - - !GetAtt EscalatorBucket.Arn 209 | - '*' 210 | - Action: 211 | - 'states:StartExecution' 212 | Effect: Allow 213 | Resource: 214 | - !Ref EscalatorStateMachine 215 | PagesTable: 216 | Type: AWS::Serverless::SimpleTable 217 | Properties: 218 | PrimaryKey: 219 | Name: id 220 | Type: String 221 | ProvisionedThroughput: 222 | ReadCapacityUnits: 3 223 | WriteCapacityUnits: 3 224 | TeamsTable: 225 | Type: AWS::Serverless::SimpleTable 226 | Properties: 227 | PrimaryKey: 228 | Name: email 229 | Type: String 230 | ProvisionedThroughput: 231 | ReadCapacityUnits: 1 232 | WriteCapacityUnits: 1 233 | IncomingEmail: 234 | Type: 'AWS::Serverless::Function' 235 | Properties: 236 | Runtime: python3.6 237 | CodeUri: ./src 238 | Handler: incomingemail.handler 239 | Description: 240 | MemorySize: 128 241 | Timeout: 10 242 | Environment: 243 | Variables: 244 | BODY_BUCKET: !Ref EscalatorBucket 245 | BODY_PREFIX: incoming 246 | SFN_ARN: !Ref EscalatorStateMachine 247 | Role: !GetAtt IncomingEmailRole.Arn 248 | IncomingEmailPolicy: 249 | Type: AWS::Lambda::Permission 250 | Properties: 251 | FunctionName: !GetAtt IncomingEmail.Arn 252 | Action: 'lambda:InvokeFunction' 253 | Principal: ses.amazonaws.com 254 | SourceAccount: !Ref 'AWS::AccountId' 255 | RegisterPage: 256 | Type: 'AWS::Serverless::Function' 257 | Properties: 258 | Runtime: python3.6 259 | CodeUri: ./src 260 | Handler: registerpage.handler 261 | Description: 262 | MemorySize: 128 263 | Timeout: 10 264 | Environment: 265 | Variables: 266 | DDB_PAGES_TABLE: !Ref PagesTable 267 | DDB_TEAMS_TABLE: !Ref TeamsTable 268 | ACK_API_URL: !Sub "https://${Domain}/ack" 269 | Role: !GetAtt EscalatorLambdaRole.Arn 270 | SendPage: 271 | Type: 'AWS::Serverless::Function' 272 | Properties: 273 | Runtime: python3.6 274 | CodeUri: ./src 275 | Handler: sendpage.handler 276 | Description: 277 | MemorySize: 128 278 | Timeout: 10 279 | Environment: 280 | Variables: 281 | DDB_PAGES_TABLE: !Ref PagesTable 282 | DDB_TEAMS_TABLE: !Ref TeamsTable 283 | SES_DOMAIN: !Ref SESDomain 284 | Role: !GetAtt EscalatorLambdaRole.Arn 285 | CheckAck: 286 | Type: 'AWS::Serverless::Function' 287 | Properties: 288 | Runtime: python3.6 289 | CodeUri: ./src 290 | Handler: checkack.handler 291 | Description: 292 | MemorySize: 128 293 | Timeout: 10 294 | Environment: 295 | Variables: 296 | DDB_PAGES_TABLE: !Ref PagesTable 297 | Role: !GetAtt EscalatorLambdaRole.Arn 298 | EscalatorStateMachine: 299 | Type: AWS::StepFunctions::StateMachine 300 | Properties: 301 | RoleArn: !GetAtt EscalatorStateMachineRole.Arn 302 | DefinitionString: 303 | !Sub 304 | - |- 305 | { 306 | "StartAt": "NewPage", 307 | "Comment": "Escalator State Machine - Pager Escalations", 308 | "States": { 309 | "NewPage": { 310 | "Type": "Task", 311 | "Resource": "${RegisterPageArn}", 312 | "Next": "PageOnCall" 313 | }, 314 | "WaitForNextCheck": { 315 | "Type": "Wait", 316 | "SecondsPath": "$.waitseconds", 317 | "Next": "CheckAck" 318 | }, 319 | "PageOnCall": { 320 | "Type": "Task", 321 | "Resource": "${SendPageArn}", 322 | "Next": "WaitForNextCheck" 323 | }, 324 | "CheckAck": { 325 | "Type": "Task", 326 | "Resource": "${CheckAckArn}", 327 | "Next": "Ack?" 328 | }, 329 | "Ack?": { 330 | "Type": "Choice", 331 | "Choices": [ 332 | { 333 | "Variable": "$.ack", 334 | "BooleanEquals": false, 335 | "Next": "PageOnCall" 336 | }, 337 | { 338 | "Variable": "$.ack", 339 | "BooleanEquals": true, 340 | "Next": "Done" 341 | } 342 | ] 343 | }, 344 | "Done": { 345 | "Type": "Succeed" 346 | } 347 | } 348 | } 349 | - { 350 | RegisterPageArn: !GetAtt RegisterPage.Arn, 351 | SendPageArn: !GetAtt SendPage.Arn, 352 | CheckAckArn: !GetAtt CheckAck.Arn 353 | } 354 | EscalatorAPI: 355 | Type: AWS::Serverless::Api 356 | DependsOn: 357 | - PagesTable 358 | - EscalatorStateMachine 359 | - EscalatorAPIRole 360 | Properties: 361 | DefinitionBody: 362 | 'Fn::Transform': 363 | Name: AWS::Include 364 | Parameters: 365 | Location: !Sub ${EscalatorAPIURI} 366 | StageName: !Ref Env 367 | Variables: 368 | pagestable: !Ref PagesTable 369 | teamstable: !Ref TeamsTable 370 | sfnescalator: !Ref EscalatorStateMachine 371 | domain: !If [ HasDomain, !Ref Domain, "testdomain.null" ] 372 | EscalatorAPIDomain: 373 | Type: AWS::ApiGateway::DomainName 374 | Condition: HasDomain 375 | Properties: 376 | DomainName: !Ref Domain 377 | CertificateArn: !Ref DomainCertArn 378 | EscalatorAPIMapping: 379 | Type: AWS::ApiGateway::BasePathMapping 380 | Condition: HasDomain 381 | Properties: 382 | Stage: !Ref Env 383 | DomainName: !Ref EscalatorAPIDomain 384 | RestApiId: !Ref EscalatorAPI 385 | R53RecordSet: 386 | Type: AWS::Route53::RecordSet 387 | Condition: HasDomain 388 | Properties: 389 | Name: !Sub '${Domain}.' 390 | AliasTarget: 391 | DNSName: !GetAtt EscalatorAPIDomain.DistributionDomainName 392 | HostedZoneId: Z2FDTNDATAQYW2 393 | HostedZoneId: !Ref Route53Zone 394 | Type: A 395 | 396 | Outputs: 397 | StepFunctionsArn: 398 | Value: !Ref EscalatorStateMachine 399 | IncomingEmailARN: 400 | Value: !GetAtt IncomingEmail.Arn 401 | EscalatorBucket: 402 | Value: !Ref EscalatorBucket 403 | ApiUrl: 404 | Description: URL of your API endpoint 405 | Value: !If 406 | - HasDomain 407 | - !Sub "https://${Domain}/" 408 | - !Sub "https://${EscalatorAPI}.execute-api.${AWS::Region}.amazonaws.com/${Env}" 409 | --------------------------------------------------------------------------------