diff --git a/.gitignore b/.gitignore index f5c66aa..75cb3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ node_modules .DS_Store packaged.yaml deploy.sh -samconfig.toml +*.toml diff --git a/README.md b/README.md index 50bd5b9..1940457 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This project provides example of serverless integration for SaaS products listed on the AWS Marketplace. -If you are a new seller on AWS Marketplace, we advise you to check the following resources: +If you are a new seller on AWS Marketplace, we advise you to check the following resources: * [SaaS Product Requirements & Recommendations](https://docs.aws.amazon.com/marketplace/latest/userguide/saas-guidelines.html) : This document outlines the requirements that must be met before gaining approval to publish a SaaS product to the catalog. * [SaaS Listing Process & Integration Guide](https://awsmp-loadforms.s3.amazonaws.com/AWS+Marketplace+-+SaaS+Integration+Guide.pdf) : This document outlines what is required to integrate with Marketplace for each SaaS pricing model. You will find integration diagrams, codes examples, FAQs, and additional resources. @@ -15,11 +15,12 @@ If you are a new seller on AWS Marketplace, we advise you to check the following # Project Structure -The sample in this repository demonstrates how to use AWS SAM (Serverless application mode) to integrate your SaaS product with AWS Marketplace and how to perform: +The sample in this repository demonstrates how to use AWS SAM (Serverless application model) to integrate your SaaS product with AWS Marketplace and how to perform: - [Register new customers](#register-new-customers) - [Grant and revoke access to your product](#grant-and-revoke-access-to-your-product) - [Metering for usage](#metering-for-usage) +- [Deploying the sample application using Serverless Application Model Command Line Interface (SAM CLI)](#) ## Register new customers @@ -37,10 +38,10 @@ You can choose to use your existing SaaS registration page, after collecting the ### Implementation -In this sample we created CloudFront Distribution, which can be configured to use domain/CNAME by your choice. The POST request coming from AWS Marketplace is intercepted by the Edge `src/lambda-edge/edge-redirect.js`, which transforms the POST request to GET request, and passes the x-amzn-marketplace-token in the query string. -We have created static HTML page hosted on S3 which takes the users inputs defined in the html form and submits them to marketplace/customer endpoint. +In this sample we created CloudFront Distribution, which can be configured to use domain/CNAME by your choice. The POST request coming from AWS Marketplace is intercepted by the Edge `src/redirect.js`, which transforms the POST request to a GET request, and passes the x-amzn-marketplace-token in the query string. +A static landing page hosted on S3 which takes the users inputs defined in the html form and submits them to the /subscriber API Gateway endpoint. << [!NOTE] +For simplicity, we use [AWS CloudShell](https://docs.aws.amazon.com/cloudshell/latest/userguide/welcome.html) to deploy the application since it has the required tools pre-installed. If you wish to run the deployment in an alternate shell, you'll need to install [Docker community edition](https://hub.docker.com/search/?type=edition&offering=community), [Node.js 10 (including NPM)](https://nodejs.org/en/), [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), and [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html). + + +To build and deploy your application for the first time, complete the following steps. + + +1. Using the AWS account registered as your [AWS Marketplace Seller account](https://docs.aws.amazon.com/marketplace/latest/userguide/seller-registration-process.html), open [AWS CloudShell](https://us-east-1.console.aws.amazon.com/cloudshell). + +2. Clone the **aws-marketplace-serverless-saas-integration repository** and change to the root of the repository. -#Replace all `` with their `actual values` (e.g. `` with `My Cool Project`). -#Create non-public bucket for before running this script + ```bash + git clone https://github.com/aws-samples/aws-marketplace-serverless-saas-integration.git + ``` -sam build -sam package --output-template-file packaged.yaml --s3-bucket +3. Change to the root directory of the repository -sam deploy --template-file packaged.yaml --stack-name --capabilities CAPABILITY_IAM \ ---region us-east-1 \ ---parameter-overrides \ -ParameterKey=WebsiteS3BucketName,ParameterValue= \ -ParameterKey=ProductCode,ParameterValue= \ -ParameterKey=EntitlementSNSTopic,ParameterValue= \ -ParameterKey=SubscriptionSNSTopic,ParameterValue= \ -ParameterKey=MarketplaceTechAdminEmail,ParameterValue= + ```bash + cd aws-marketplace-serverless-saas-integration + ``` -#If you want to notify your buyer of successful purchase then you can use the following to deploy your application -sam deploy --template-file packaged.yaml --stack-name --capabilities CAPABILITY_IAM \ ---region us-east-1 \ ---parameter-overrides \ -ParameterKey=WebsiteS3BucketName,ParameterValue= \ -ParameterKey=ProductCode,ParameterValue= \ -ParameterKey=EntitlementSNSTopic,ParameterValue= \ -ParameterKey=SubscriptionSNSTopic,ParameterValue= \ -ParameterKey=MarketplaceTechAdminEmail,ParameterValue= \ -ParameterKey=MarketplaceSellerEmail,ParameterValue= +4. Build the application using SAM. -#Check the account for and approve the subscription to SNS + ```bash + sam build + ``` -#Replace the baseUrl in web/script.js with the API Gateway endpoint URL +5. Deploy the application using the SAM guided experience. -aws s3 cp ./web/ s3:/// --recursive + ```bash + sam deploy --guided --capabilities CAPABILITY_NAMED_IAM + ``` -#add a CNAME record to your DNS to route the url you put on your offering to the cloudformation endpoint +6. Follow the SAM guided experience to configure the deployment. Reference the following table for solution parameters. + + Parameter name | Description + ------------ | ------------- + Stack Name | Name of the resulting CloudFormation stack. + AWS Region | Name of the region that the solution is being deployed in. Default value: us-east-1 + WebsiteS3BucketName | S3 bucket to store the HTML files; Mandatory if CreateRegistrationWebPage is set to true; will be created + NewSubscribersTableName | Name for the New Subscribers Table; Default value: AWSMarketplaceSubscribers + AWSMarketplaceMeteringRecordsTableName | Name for the Metering Records Table; Default value: AWSMarketplaceMeteringRecords + TypeOfSaaSListing | allowed values: contracts_with_subscription, contracts, subscriptions; Default value: contracts_with_subscription + ProductCode | Product code provided from AWS Marketplace + MarketplaceTechAdminEmail | Email to be notified on changes requiring action + MarketplaceSellerEmail | (Optional) Seller email address, verified in SES and in 'Production' mode. See [Verify an email address](https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses-procedure.html) for instruction to verify email addresses. + SNSAccountID | AWS account ID hosting the Entitlements and Subscriptions SNS topics. Leave as default. + SNSRegion | AWS region that the Entitlements and Subscriptions SNS topics are hosted in. Leave as default. + CreateCrossAccountRole | Creates a cross-account role granting access to the NewSubscribersTableName and AWSMarketplaceMeteringRecordsTableName tables. Default value: false. + CrossAccountId | (Optional) AWS account ID for the cross-account role. + CrossAccountRoleName | (Optional) Role name for the cross-account role. + CreateRegistrationWebPage | Creates a registration page. Default value: true -#add the domain used for your marketplace URL to the CNAME on the cloudformation config +7. Wait for the stack to complete successfully. + +8. Check the email account for **MarketplaceTechAdminEmail** and approve the subscription to the SNS topic. + +9. If a registration page was created, copy the web files into the WebsiteS3BucketName. + +```bash +aws s3 cp ./web/ s3:/// --recursive ``` -### List of parameters - -Parameter name | Description ------------- | ------------- -WebsiteS3BucketName | S3 bucket to store the HTML files; Mandatory if CreateRegistrationWebPage is set to true; will be created -NewSubscribersTableName | Use customer name for the New Subscribers Table; Default value: AWSMarketplaceSubscribers -AWSMarketplaceMeteringRecordsTableName | Use customer name for the Metering Records Table; Default value: AWSMarketplaceMeteringRecords -TypeOfSaaSListing | allowed values: contracts_with_subscription, contracts, subscriptions; Default value: contracts_with_subscription -ProductCode | Product code provided from AWS Marketplace -EntitlementSNSTopic | SNS topic ARN provided from AWS Marketplace. Must exist. -SubscriptionSNSTopic | SNS topic ARN provided from AWS Marketplace. Must exist. -CreateRegistrationWebPage | true or false; Default value: true -MarketplaceTechAdminEmail | Email to be notified on changes requiring action -MarketplaceSellerEmail | Seller email address, verified in SES and in 'Production' mode ### Diagram of created resources @@ -239,7 +242,7 @@ In the case of a *subscriptions* the resources market with purple circles will n The landing page is optional. Use the CreateRegistrationWebPage parameter. -![](misc/Serverless-MP.png) +![](misc/AWS-Marketplace-SaaS-Integration.drawio.png) ## Cleanup @@ -258,3 +261,19 @@ See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more inform ## License This library is licensed under the MIT-0 License. See the LICENSE file. + + +## Post deployment steps + +## Registration page is true +1. Update the MarketplaceFulfillmentUrl in your AWS Marketplace Management Portal with the value from the output key 'MarketplaceFulfillmentUrl'. The value would be in a the form of a AWS cloudfront based url. +2. Replace the baseUrl value in the web/script.js file from the web template provided with the value from the output key 'RedirectUrl'. +3. Replace the RedirectUrl value in the lambda environment variable with the value from the output key 'RedirectUrl'. Navigate to the AWS Console, look for AWS Lambda service, filter to the lambda with name ....Redirect... . Select the lambda function, go to configuration tab and then select the environment variable. +4. Ensure the email address used is a verified identity/domain in Amazon Simple Email Service. +5. Ensure your Amazon Simple Email Service account is a production account. + +## Registration page is false +1. Update the MarketplaceFulfillmentUrl in your AWS Marketplace Management Portal with the value from the output key 'MarketplaceFulfillmentUrl'. The value would be in the form of an AWS API gateway url. +2. Replace the baseUrl value in the web/script.js file from the web template provided with the value from the output key 'RedirectUrl'. +3. Ensure the email address used is a verified identity/domain in Amazon Simple Email Service. +4. Ensure your Amazon Simple Email Service account is a production account. diff --git a/buildspec.yaml b/buildspec.yaml index c91537c..f557fae 100644 --- a/buildspec.yaml +++ b/buildspec.yaml @@ -10,36 +10,44 @@ phases: - npm install aws-cli-js - echo "${CODEBUILD_WEBHOOK_TRIGGER}" - echo "${AWSMarketplaceMeteringRecordsTableName}" - - echo "${EntitlementSNSTopic}" - echo "${MarketplaceTechAdminEmail}" - echo "${NewSubscribersTableName}" - echo "${ProductCode}" - - echo "${SubscriptionSNSTopic}" - echo "${TypeOfSaaSListing}" - echo "${WebsiteS3BucketName}" + - echo "${SNSAccountID}" + - echo "${SNSRegion}" + - echo "${CreateCrossAccountRole}" + - echo "${CrossAccountId}" + - echo "${CrossAccountRoleName}" + - echo "${CreateRegistrationWebPage}" build: commands: - echo Build started - sam build - - sam package --output-template-file packaged.yaml --s3-bucket saas-artifact-1 --region us-east-1 - | - sam deploy --template-file packaged.yaml --stack-name saas-serverless-${CODEBUILD_BUILD_NUMBER} --capabilities CAPABILITY_IAM \ + sam deploy --stack-name saas-serverless-${CODEBUILD_BUILD_NUMBER} --s3-bucket saas-artifact-1 --capabilities CAPABILITY_NAMED_IAM \ --region us-east-1 \ --parameter-overrides \ ParameterKey=WebsiteS3BucketName,ParameterValue=${WebsiteS3BucketName}-${CODEBUILD_BUILD_NUMBER} \ - ParameterKey=ProductCode,ParameterValue=${ProductCode} \ - ParameterKey=EntitlementSNSTopic,ParameterValue=${EntitlementSNSTopic} \ - ParameterKey=SubscriptionSNSTopic,ParameterValue=${SubscriptionSNSTopic} \ - ParameterKey=AWSMarketplaceMeteringRecordsTableName,ParameterValue=${AWSMarketplaceMeteringRecordsTableName}-${CODEBUILD_BUILD_NUMBER} \ ParameterKey=NewSubscribersTableName,ParameterValue=${NewSubscribersTableName}-${CODEBUILD_BUILD_NUMBER} \ + ParameterKey=AWSMarketplaceMeteringRecordsTableName,ParameterValue=${AWSMarketplaceMeteringRecordsTableName}-${CODEBUILD_BUILD_NUMBER} \ + ParameterKey=ProductCode,ParameterValue=${ProductCode} \ ParameterKey=MarketplaceTechAdminEmail,ParameterValue=${MarketplaceTechAdminEmail} \ - ParameterKey=MarketplaceSellerEmail,ParameterValue=${MarketplaceTechAdminEmail} + ParameterKey=MarketplaceSellerEmail,ParameterValue=${MarketplaceTechAdminEmail} \ + ParameterKey=SNSAccountID,ParameterValue=${SNSAccountID} \ + ParameterKey=SNSRegion,ParameterValue=${SNSRegion} \ + ParameterKey=CreateCrossAccountRole,ParameterValue=${CreateCrossAccountRole} \ + ParameterKey=CrossAccountId,ParameterValue=${CrossAccountId} \ + ParameterKey=CrossAccountRoleName,ParameterValue=${CrossAccountRoleName}-${CODEBUILD_BUILD_NUMBER} \ + ParameterKey=CreateRegistrationWebPage,ParameterValue=${CreateRegistrationWebPage} aws s3 cp ./web/ s3://${WebsiteS3BucketName}-${CODEBUILD_BUILD_NUMBER}/ --recursive post_build: commands: - echo Build completed - - LANDING_PAGE=$(aws cloudformation describe-stacks --stack-name saas-serverless-${CODEBUILD_BUILD_NUMBER} --query "Stacks[0].Outputs[1].OutputValue" --output text --region us-east-1) + - LANDING_PAGE=$(aws cloudformation describe-stacks --stack-name saas-serverless-${CODEBUILD_BUILD_NUMBER} --query "Stacks[0].Outputs[0].OutputValue" --output text --region us-east-1) + - echo $LANDING_PAGE - TEST_LANDING_PAGE=$(curl -I -s ${LANDING_PAGE} | head -n 1 | cut -d$' ' -f2) - echo $TEST_LANDING_PAGE - | diff --git a/misc/AWS Marketplace SaaS Integration.drawio b/misc/AWS Marketplace SaaS Integration.drawio new file mode 100644 index 0000000..bd256ac --- /dev/null +++ b/misc/AWS Marketplace SaaS Integration.drawio @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/misc/AWS Marketplace SaaS Integration.drawio.png b/misc/AWS Marketplace SaaS Integration.drawio.png new file mode 100644 index 0000000..0f6d229 Binary files /dev/null and b/misc/AWS Marketplace SaaS Integration.drawio.png differ diff --git a/misc/AWS-Marketplace-SaaS-Integration.drawio.png b/misc/AWS-Marketplace-SaaS-Integration.drawio.png new file mode 100644 index 0000000..0f6d229 Binary files /dev/null and b/misc/AWS-Marketplace-SaaS-Integration.drawio.png differ diff --git a/src/entitlement-sqs.js b/src/entitlement-sqs.js index 6858037..20199f2 100644 --- a/src/entitlement-sqs.js +++ b/src/entitlement-sqs.js @@ -1,8 +1,8 @@ const AWS = require('aws-sdk'); - -const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); +const { NewSubscribersTableName: newSubscribersTableName, AWS_REGION: aws_region } = process.env; +// MarketplaceEntitlementService is instantianise only in the us-east-1 https://docs.aws.amazon.com/general/latest/gr/aws-marketplace.html#marketplaceentitlement const marketplaceEntitlementService = new AWS.MarketplaceEntitlementService({ apiVersion: '2017-01-11', region: 'us-east-1' }); -const { NewSubscribersTableName: newSubscribersTableName } = process.env; +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: aws_region }); exports.handler = async (event) => { await Promise.all(event.Records.map(async (record) => { @@ -47,7 +47,5 @@ exports.handler = async (event) => { throw new Error(`Unhandled action - msg: ${JSON.stringify(record)}`); } })); - - return {}; }; diff --git a/src/grant-revoke-access-to-product.js b/src/grant-revoke-access-to-product.js index 5444938..fe3b10a 100644 --- a/src/grant-revoke-access-to-product.js +++ b/src/grant-revoke-access-to-product.js @@ -1,6 +1,5 @@ const winston = require('winston'); const AWS = require('aws-sdk'); - const SNS = new AWS.SNS({ apiVersion: '2010-03-31' }); const { SupportSNSArn: TopicArn } = process.env; const logger = winston.createLogger({ diff --git a/src/lambda-edge/edge-redirect.js b/src/lambda-edge/edge-redirect.js deleted file mode 100644 index c6c465f..0000000 --- a/src/lambda-edge/edge-redirect.js +++ /dev/null @@ -1,22 +0,0 @@ -exports.lambdaHandler = async (event) => { - const { request } = event.Records[0].cf; - - const redirect = request.method === 'POST' && request.body.data; - - - if (redirect) { - const body = Buffer.from(request.body.data, 'base64').toString(); - return { - status: '302', - statusDescription: 'Found', - headers: { - location: [{ - key: 'Location', - value: `/?${body}`, - }], - }, - }; - } - - return request; -}; diff --git a/src/lambda-edge/package.json b/src/lambda-edge/package.json deleted file mode 100644 index 3241313..0000000 --- a/src/lambda-edge/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "lambda-edge-function", - "version": "1.0.0" -} diff --git a/src/metering-hourly-job.js b/src/metering-hourly-job.js index a45c6c9..ded3633 100644 --- a/src/metering-hourly-job.js +++ b/src/metering-hourly-job.js @@ -1,9 +1,8 @@ const AWS = require('aws-sdk'); - -const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); -const sqs = new AWS.SQS({ apiVersion: '2012-11-05', region: 'us-east-1' }); - -const { SQSMeteringRecordsUrl: QueueUrl, AWSMarketplaceMeteringRecordsTableName } = process.env; +const { AWS_REGION: aws_region } = process.env; +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: aws_region }); +const sqs = new AWS.SQS({ apiVersion: '2012-11-05', region: aws_region }); +const { SQSMeteringRecordsUrl: QueueUrl, AWSMarketplaceMeteringRecordsTableName: AWSMarketplaceMeteringRecordsTableName } = process.env; async function asyncForEach(array, callback) { diff --git a/src/metering-sqs.js b/src/metering-sqs.js index 5f33750..be6815f 100644 --- a/src/metering-sqs.js +++ b/src/metering-sqs.js @@ -1,16 +1,14 @@ const AWS = require('aws-sdk'); - -const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); +const { ProductCode: ProductCode, AWSMarketplaceMeteringRecordsTableName: AWSMarketplaceMeteringRecordsTableName , AWS_REGION: aws_region } = process.env; +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: aws_region }); +// MarketplaceMetering is instantianize in us-east-1 as all SaaS product listing ARN is stored in us-east-1. const marketplacemetering = new AWS.MarketplaceMetering({ apiVersion: '2016-01-14', region: 'us-east-1' }); -const { ProductCode, AWSMarketplaceMeteringRecordsTableName } = process.env; - exports.handler = async (event) => { await Promise.all(event.Records.map(async (record) => { const body = JSON.parse(record.body); console.log(`SQS message body: ${record.body}`); - const timestmpNow = new Date(); const UsageRecords = []; diff --git a/src/redirect.js b/src/redirect.js new file mode 100644 index 0000000..ce17399 --- /dev/null +++ b/src/redirect.js @@ -0,0 +1,13 @@ +exports.redirecthandler = async(event, context, callback) => { + + const redirectUrl = "/?" + event['body']; + const response = { + statusCode: 302, + headers: { + Location: redirectUrl + }, + }; + + return response; + +}; diff --git a/src/register-new-subscriber.js b/src/register-new-subscriber.js index b922d64..729d36d 100644 --- a/src/register-new-subscriber.js +++ b/src/register-new-subscriber.js @@ -1,9 +1,9 @@ const AWS = require('aws-sdk'); -const ses = new AWS.SES({ region: "us-east-1" }); -const marketplacemetering = new AWS.MarketplaceMetering({ apiVersion: '2016-01-14', region: 'us-east-1' }); -const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); -const sqs = new AWS.SQS({ apiVersion: '2012-11-05', region: 'us-east-1' }); -const { NewSubscribersTableName: newSubscribersTableName, EntitlementQueueUrl: entitlementQueueUrl, MarketplaceSellerEmail: marketplaceSellerEmail } = process.env; +const { NewSubscribersTableName: newSubscribersTableName, EntitlementQueueUrl: entitlementQueueUrl, MarketplaceSellerEmail: marketplaceSellerEmail, AWS_REGION:aws_region } = process.env; +const ses = new AWS.SES({ region: aws_region}); +const marketplacemetering = new AWS.MarketplaceMetering({ apiVersion: '2016-01-14', region: aws_region }); +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: aws_region }); +const sqs = new AWS.SQS({ apiVersion: '2012-11-05', region: aws_region }); const lambdaResponse = (statusCode, body) => ({ statusCode, diff --git a/src/subscription-sqs.js b/src/subscription-sqs.js index 836dbee..694fb02 100644 --- a/src/subscription-sqs.js +++ b/src/subscription-sqs.js @@ -1,8 +1,8 @@ const AWS = require('aws-sdk'); - -const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); +const { SupportSNSArn: TopicArn, NewSubscribersTableName: newSubscribersTableName, AWS_REGION: aws_region } = process.env; +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: aws_region }); const SNS = new AWS.SNS({ apiVersion: '2010-03-31' }); -const { SupportSNSArn: TopicArn, NewSubscribersTableName: newSubscribersTableName } = process.env; + exports.SQSHandler = async (event) => { await Promise.all(event.Records.map(async (record) => { diff --git a/template.yaml b/template.yaml index 969a324..b72a5ae 100644 --- a/template.yaml +++ b/template.yaml @@ -1,8 +1,6 @@ AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 -Description: > - app - Sample SAM Template for app +Description: "AWS Marketplace Serverless SaaS Integration Application" Globals: Function: Timeout: 15 @@ -14,8 +12,11 @@ Globals: AllowCredentials: "'*'" Parameters: + WebsiteS3BucketName: Type: String + #AllowedPattern: "(?!(^xn--|.+-s3alias$))^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$" + ConstraintDescription: "S3 bucket name must follow S3 recommendations https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html" Default: "" NewSubscribersTableName: @@ -36,6 +37,19 @@ Parameters: - contracts - subscriptions + SNSAccountID: + Type: String + Default: "287250355862" + Description: "This is the AWS account hosting the SNS Entitlement and Subscription topics for your product." + AllowedValues: + - "287250355862" + + SNSRegion: + Type: String + Default: "us-east-1" + Description: "This is the AWS region of the SNS Entitlement and Subscription topics for your product." + AllowedValues: + - us-east-1 ProductCode: Type: String AllowedPattern: ".*" @@ -49,18 +63,29 @@ Parameters: AllowedPattern: ".*" Default: "" - EntitlementSNSTopic: + CreateCrossAccountRole: + Default: "false" + Description: "Do you intend to use cross account access with this integration core?" + Type: String + AllowedValues: + - "true" + - "false" + + CrossAccountId: + Default: '' + Description: "Enter the cross AWS account id" Type: String - Default: "" - SubscriptionSNSTopic: + CrossAccountRoleName: Type: String - Default: "" + Description: "Your Role Name (ex: OrganizationAccountAccessRole); This will need to be the same across all of the Member Accounts" CreateRegistrationWebPage: - Default: true + Default: "true" Type: String - AllowedValues: [true, false] + AllowedValues: + - "true" + - "false" Conditions: CreateEntitlementLogic: @@ -75,114 +100,56 @@ Conditions: CreateWeb: !Equals [!Ref CreateRegistrationWebPage, true] Buyernotificationemail: !Not [!Equals [!Ref MarketplaceSellerEmail, ""]] + CreateCrossAccount: !Equals [!Ref CreateCrossAccountRole, true] -Resources: - CloudFrontOriginAccessIdentity: - Type: "AWS::CloudFront::CloudFrontOriginAccessIdentity" - Condition: CreateWeb - Properties: - CloudFrontOriginAccessIdentityConfig: - Comment: "Serverless website OA" - - CloudfrontDistribution: - Type: "AWS::CloudFront::Distribution" - Condition: CreateWeb - Properties: - DistributionConfig: - Comment: "Cloudfront distribution for serverless website" - DefaultRootObject: "index.html" - Enabled: true - HttpVersion: http2 - # List of origins that Cloudfront will connect to - Origins: - - Id: s3-website - DomainName: !GetAtt WebsiteS3Bucket.DomainName - S3OriginConfig: - # Restricting Bucket access through an origin access identity - OriginAccessIdentity: - Fn::Sub: "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}" - # To connect the CDN to the origins you need to specify behaviours - DefaultCacheBehavior: - # Compress resources automatically ( gzip ) - Compress: true - AllowedMethods: - - HEAD - - DELETE - - POST - - GET - - OPTIONS - - PUT - - PATCH - ForwardedValues: - QueryString: false - LambdaFunctionAssociations: - - EventType: viewer-request - LambdaFunctionARN: !Ref LambdaEdgeRedirectPostRequests.Version - IncludeBody: true - TargetOriginId: s3-website - ViewerProtocolPolicy: redirect-to-https - Logging: - Bucket: !GetAtt WebsiteS3BucketLog.DomainName - IncludeCookies: false - Prefix: "access-logs" - - WebsiteS3Bucket: - Type: AWS::S3::Bucket - Condition: CreateWeb - Properties: - BucketName: !Ref WebsiteS3BucketName - WebsiteS3BucketLog: - Type: AWS::S3::Bucket - Condition: CreateWeb - Properties: - BucketName: !Join ["-", [!Ref WebsiteS3BucketName, "log"]] - OwnershipControls: - Rules: - - ObjectOwnership: BucketOwnerPreferred - IntelligentTieringConfigurations: - - Id: !Join ["-", [!Ref WebsiteS3BucketName, "log"]] - Status: Enabled - Tierings: - - AccessTier: ARCHIVE_ACCESS - Days: 90 +Resources: - S3BucketPolicy: - Type: AWS::S3::BucketPolicy - Condition: CreateWeb + ServerlessApi: + Type: AWS::Serverless::Api Properties: - Bucket: !Ref WebsiteS3Bucket - PolicyDocument: - # Restricting access to cloudfront only. - Statement: - - Effect: Allow - Action: "s3:GetObject" - Resource: - - !Sub "arn:aws:s3:::${WebsiteS3Bucket}/*" - Principal: - AWS: !Sub "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}" - - LambdaEdgeRedirectPostRequests: - Type: AWS::Serverless::Function - Condition: CreateWeb - UpdateReplacePolicy: Delete - DeletionPolicy: Delete + StageName: Prod + MethodSettings: + - ResourcePath: /subscriber + HttpMethod: POST, OPTIONS + - ResourcePath: /redirectmarketplacetoken + HttpMethod: POST, OPTIONS + + CrossAccountRoleForSaaSIntegration: + Type: AWS::IAM::Role + Condition: CreateCrossAccount + DependsOn: + - AWSMarketplaceMeteringRecords + - AWSMarketplaceSubscribers Properties: - Runtime: nodejs18.x - CodeUri: src/lambda-edge/ - Handler: edge-redirect.lambdaHandler - Timeout: 5 - AutoPublishAlias: live + RoleName: !Join ["-", [!Ref "AWS::StackName", !Ref CrossAccountRoleName]] AssumeRolePolicyDocument: - Version: "2012-10-17" + Version: 2012-10-17 Statement: - - Effect: "Allow" - Action: "sts:AssumeRole" + - Effect: Allow Principal: - Service: - - "lambda.amazonaws.com" - - "edgelambda.amazonaws.com" - + AWS: + - !Join [":", ["arn:aws:iam:", !Ref CrossAccountId , "root"]] + Action: + - 'sts:AssumeRole' + Condition: + StringEquals: + 'sts:ExternalId': !Ref CrossAccountRoleName + Path: / + Policies: + - PolicyName: !Join ["-", [!Ref "AWS::StackName", "CrossAccountPolicy"]] + PolicyDocument: + Version: 2012-10-17 + Statement: + - Effect: Allow + Action: + - 'dynamodb:PutItem' + - 'dynamodb:DeleteItem' + - 'dynamodb:UpdateItem' + Resource: + - !GetAtt AWSMarketplaceMeteringRecords.Arn + - !GetAtt AWSMarketplaceSubscribers.Arn + AWSMarketplaceMeteringRecords: Type: AWS::DynamoDB::Table Condition: CreateSubscriptionLogic @@ -223,7 +190,7 @@ Resources: TableName: !Ref NewSubscribersTableName StreamSpecification: StreamViewType: NEW_AND_OLD_IMAGES - + RegisterNewMarketplaceCustomer: Type: AWS::Serverless::Function Properties: @@ -234,13 +201,9 @@ Resources: Variables: NewSubscribersTableName: !Ref NewSubscribersTableName EntitlementQueueUrl: - !If [CreateEntitlementLogic, !Ref EntitlementSQSQueue, ""] + !If [CreateEntitlementLogic, !Ref EntitlementSQSQueue, !Ref AWS::NoValue] MarketplaceSellerEmail: - Fn::If: - - Buyernotificationemail - - Ref: MarketplaceSellerEmail - - Ref: AWS::NoValue - + !If [Buyernotificationemail, !Ref MarketplaceSellerEmail, !Ref AWS::NoValue] Policies: - DynamoDBWritePolicy: TableName: !Ref NewSubscribersTableName @@ -272,6 +235,7 @@ Resources: Properties: Path: /subscriber Method: post + RestApiId: !Ref ServerlessApi EntitlementSQSQueue: Type: AWS::SQS::Queue @@ -302,7 +266,8 @@ Resources: MySQSEvent: Type: SNS Properties: - Topic: !Ref EntitlementSNSTopic + Topic: !Sub 'arn:aws:sns:${SNSRegion}:${SNSAccountID}:aws-mp-entitlement-notification-${ProductCode}' + Region: !Sub '${SNSRegion}' SqsSubscription: BatchSize: 1 QueueArn: !GetAtt EntitlementSQSQueue.Arn @@ -331,7 +296,9 @@ Resources: MySQSEvent: Type: SNS Properties: - Topic: !Ref SubscriptionSNSTopic + #Topic: !Ref SubscriptionSNSTopic + Topic: !Sub 'arn:aws:sns:${SNSRegion}:${SNSAccountID}:aws-mp-subscription-notification-${ProductCode}' + Region: !Sub '${SNSRegion}' SqsSubscription: true SupportSNSTopic: @@ -390,7 +357,7 @@ Resources: Schedule: "rate(1 hour)" Name: !Join ["-", [MeteringSchedule, !Ref AWS::StackName]] Description: SaaS Metering - Enabled: TRUE + Enabled: true SQSMeteringRecords: Type: AWS::SQS::Queue @@ -427,16 +394,198 @@ Resources: Properties: Queue: !GetAtt SQSMeteringRecords.Arn BatchSize: 1 + + + Bucket: !GetAtt WebsiteS3BucketLog.DomainName + IncludeCookies: false + Prefix: "access-logs" + + WebsiteS3Bucket: + Type: AWS::S3::Bucket + Condition: CreateWeb + Properties: + BucketName: !Ref WebsiteS3BucketName + + WebsiteS3BucketLog: + Type: AWS::S3::Bucket + Condition: CreateWeb + Properties: + BucketName: !Join ["-", [!Ref WebsiteS3BucketName, "log"]] + OwnershipControls: + Rules: + - ObjectOwnership: BucketOwnerPreferred + IntelligentTieringConfigurations: + - Id: !Join ["-", [!Ref WebsiteS3BucketName, "log"]] + Status: Enabled + Tierings: + - AccessTier: ARCHIVE_ACCESS + Days: 90 + + S3BucketPolicy: + Type: AWS::S3::BucketPolicy + Condition: CreateWeb + Properties: + Bucket: !Ref WebsiteS3Bucket + PolicyDocument: + # Restricting access to cloudfront only. + Statement: + - Effect: Allow + Sid: "AllowCloudFrontServicePrincipal" + Action: "s3:GetObject" + Resource: + - !Sub "arn:aws:s3:::${WebsiteS3Bucket}/*" + Principal: + Service: "cloudfront.amazonaws.com" + Condition: + StringEquals: + "AWS:SourceArn" : + - !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudfrontDistribution}" + + + LambdaRedirectPostRequests: + Type: AWS::Serverless::Function + Condition: CreateWeb + Properties: + Runtime: nodejs18.x + CodeUri: src/ + Handler: redirect.redirecthandler + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: "sts:AssumeRole" + Principal: + Service: + - "lambda.amazonaws.com" + Environment: + Variables: + RedirectUrl: https://aws-ia.github.io/cloudformation-aws-marketplace-saas/#_post_deployment_steps + Events: + RedirectMarketplaceToken: + Type: Api + Properties: + Path: /redirectmarketplacetoken + Method: post + RestApiId: !Ref ServerlessApi + + CloudFrontOriginAccessControl: + Type: "AWS::CloudFront::OriginAccessControl" + Properties: + OriginAccessControlConfig: + Description: !Sub "Origin Access Control for static website - ${AWS::StackName}" + Name: !Sub "OAC - ${AWS::StackName}" + OriginAccessControlOriginType: "s3" + SigningBehavior: "always" + SigningProtocol: "sigv4" + + + CloudfrontDistribution: + Type: "AWS::CloudFront::Distribution" + Condition: CreateWeb + DependsOn: ServerlessApi + Properties: + DistributionConfig: + Comment: !Sub "Cloudfront distribution for serverless website - ${AWS::StackName}" + DefaultRootObject: "index.html" + Enabled: true + HttpVersion: http2 + # List of origins that Cloudfront will connect to + Origins: + - Id: s3-website + DomainName: !GetAtt WebsiteS3Bucket.RegionalDomainName + OriginAccessControlId: !Ref CloudFrontOriginAccessControl + S3OriginConfig: + OriginAccessIdentity: "" + - Id: api-gateway + DomainName: !Sub "${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com" + CustomOriginConfig: + OriginProtocolPolicy: "https-only" + OriginSSLProtocols: + - "TLSv1.2" + HTTPSPort: 443 + OriginPath: "/Prod" + # To connect the CDN to the origins you need to specify behaviours + DefaultCacheBehavior: + # Compress resources automatically ( gzip ) + Compress: true + AllowedMethods: + - HEAD + - DELETE + - POST + - GET + - OPTIONS + - PUT + - PATCH + ForwardedValues: + QueryString: false + TargetOriginId: s3-website + ViewerProtocolPolicy: redirect-to-https + CacheBehaviors: + - PathPattern: /redirectmarketplacetoken + AllowedMethods: + - HEAD + - DELETE + - POST + - GET + - OPTIONS + - PUT + - PATCH + TargetOriginId: api-gateway + ViewerProtocolPolicy: redirect-to-https + ResponseHeadersPolicyId: 60669652-455b-4ae9-85a4-c4c02393f86c + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac + - PathPattern: /subscriber + AllowedMethods: + - HEAD + - DELETE + - POST + - GET + - OPTIONS + - PUT + - PATCH + TargetOriginId: api-gateway + ViewerProtocolPolicy: redirect-to-https + ResponseHeadersPolicyId: 60669652-455b-4ae9-85a4-c4c02393f86c + CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad + OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac + Logging: + Bucket: !GetAtt WebsiteS3BucketLog.DomainName + IncludeCookies: false + Prefix: "access-logs" + + Outputs: - APIUrl: - Description: API gateway URL to replace baseUrl value in web/script.js - Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/" + CrossAccountRole: + Description: This is the cross account role ARN. + Value: + !If [ + CreateCrossAccount, + !GetAtt CrossAccountRoleForSaaSIntegration.Arn, + "N/A" + ] + WebsiteS3Bucket: + Description: S3 bucket for hosting the static site. You can retrieve the files at https://github.com/aws-samples/aws-marketplace-serverless-saas-integration/tree/master/web. + Value: + !If [ + CreateWeb, + !Sub "https://s3.console.aws.amazon.com/s3/buckets/${WebsiteS3Bucket}/", + !Sub "N/A" + ] - LandingPageUrl: - Description: URL to access your landing page and update SaaS URL field in your listing. + LandingPagePreviewURL: + Description: URL to preview your landing page. This is NOT the Fulfillment URL for your product. Value: !If [ CreateWeb, !Sub "https://${CloudfrontDistribution.DomainName}/index.html", - "N/A", + "N/A" + ] + MarketplaceFulfillmentURL: + Description: This is the Marketplace fulfillment URL. + Value: + !If [ + CreateWeb, + !Sub "https://${CloudfrontDistribution.DomainName}/redirectmarketplacetoken", + !Sub "https://${ServerlessApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/redirectmarketplacetoken" ] \ No newline at end of file diff --git a/web/script.js b/web/script.js index a13a439..adb1d58 100644 --- a/web/script.js +++ b/web/script.js @@ -1,4 +1,3 @@ -const baseUrl = 'https://API-ID.execute-api.us-east-1.amazonaws.com/Prod/'; // TODO: This needs to be replaced const form = document.getElementsByClassName('form-signin')[0]; const showAlert = (cssClass, message) => { @@ -28,7 +27,7 @@ const getUrlParameter = (name) => { const handleFormSubmit = (event) => { event.preventDefault(); - const postUrl = `${baseUrl}subscriber`; + const postUrl = `/subscriber`; const regToken = getUrlParameter('x-amzn-marketplace-token'); if (!regToken) {