diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e2cf4e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +.vscode +.aws-sam +.DS_Store +packaged.yaml +deploy.sh \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 5b627cf..a0ea08c 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,4 +1,4 @@ ## Code of Conduct This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact -opensource-codeofconduct@amazon.com with any additional questions or comments. +opensource-codeofconduct@amazon.com with any additional questions or comments. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 914e074..25f11e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,3 +59,4 @@ If you discover a potential security issue in this project we ask that you notif See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes. + diff --git a/README.md b/README.md index 7f92204..5e4b111 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,198 @@ -## My Project +# AWS Marketplace - Serverless integration for SaaS products (Example) -TODO: Fill this README out! +![](misc/banner.png) -Be sure to: +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: + +* [SaaS Product Requirements & Recommendations](https://docs.aws.amazon.com/marketplace/latest/userguide/what-is-marketplace.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. +* [SaaS Integration Video](https://www.youtube.com/watch?v=glG44f-L8us) : This video guides you through the requirements and steps needed to integrate. +* [SaaS Pricing Video](https://www.youtube.com/watch?v=E0uWp8nhzAk) : This video guides you through the pricing options available when choosing to list a SaaS product. +* [AWS Marketplace - Seller Guide](https://docs.aws.amazon.com/marketplace/latest/userguide/what-is-marketplace.html) : This document covers more information about creating a SaaS product, pricing, and setting up your integration. + +# 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: + +- [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) + + +## Register new customers + +With SaaS subscriptions and SaaS contracts, your customers subscribe to your products through AWS Marketplace, but access the product on environment you manage in your AWS account. After subscribing to the product, your customer is directed to a website you create and manage as a part of your SaaS product to register their account and configure the product. + +When creating your product, you provide a URL to your registration landing page. AWS Marketplace uses that URL to redirect customers to your registration landing page after they subscribe. On your software's registration URL, you collect whatever information is required to create an account for the customer. AWS Marketplace recommends collecting your customer’s email addresses if you plan to contact them through email for usage notifications. + +The registration landing page needs to be able to identify and accept the x-amzn-marketplace-token token in the form data from AWS Marketplace with the customer’s identifier for billing. It should then pass that token value to the AWS Marketplace Metering Service and AWS Marketplace Entitlement Service APIs to resolve for the unique customer identifier and corresponding product code. + +![](misc/onbording.gif) + +> NOTE: Deploying the static landing page is optional. +You can choose to use your existing SaaS registration page, after collecting the data you should call the register new subscriber endpoint. Please see the deployment section. + +### 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/edege-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. + +The handler for the marketplace/customer endpoint is defined in the `src/register-new-subscriber.js` file, where we call the `resolveCustomer` and validate the token. If the token is valid the customer record is created in the `AWSMarketplaceSubscribers` DynamoDB table and the new customer data are stored. + +![](misc/Onbording-CF.png) + +## Grant and revoke access to your product + +### Grant access to new subscribers + +Once the resolveCustomer endpoint return successful response, the SaaS vendors must to provide access to the solution to the new subscriber. +Based on the type of listing contract or subscription we have defined different conditions in the `environment-provisioning.js` stream handler that is executed on adding new or updating existing rows. + +In our implementation the Marketplace Tech Admin (The email adress you have entered when deplyoing), will receive email when new environment needs to be provisioned or existing environment needs to be updated. AWS Marketplace strongly recommends automating the access and environment management which can be achieved by modifying the `environment-provisioning.js` function. + +The property successfully subscribed is set when successful response is returned from the SQS entitlement handler for SaaS Contract based listings or after receiving **subscribe-success message from the Subscription SNS Topic in the case of AWS SaaS subscriptions in the `subscription-sqs-handler.js`. + + +### Update entitlement levels to new subscirbers (SaaS Contracts only) + +Each time the entitlement is update we receive message on the SNS topic. +The lambda function `eintilemnet-sqs.js` on each message is calling the marketplaceEntitlementService and storing the response in the dynamoDB. + +We are using the same DynamoDB stream to detect changes in the entailment for SaaS contracts. When the entitlement is update notification is sent to the `MarketplaceTechAdmin`. + + +### Revoke access to customers with expired contracts and canceled subscriptions + +The revoke access logic is implemented in a similar manner as the grant access logic. + +In our implementation the `MarketplaceTechAdmin` receives email when the contract expires or the subscription is cancelled. +AWS Marketplace strongly recommends automating the access and environment management which can be achieved by modifying the `environment-provisioning.js` function. + +## Metering for usage + +For SaaS subscriptions, the SaaS provider must meter for all usage, and then customers are billed by AWS based on the metering records provided. For SaaS contracts, you only meter for usage beyond a customer’s contract entitlements. When your application meters usage for a customer, your application is providing AWS with a quantity of usage accrued. Your application meters for the pricing dimensions that you defined when you created your product, such as gigabytes transferred or hosts scanned in a given hour. + +### Implementation + +We have created MeteringSchedule CloudWatch Event rule that is **triggered every hour**. The `metering-hourly-job.js` gets triggered by this rule and it's querying all of the pending/unreported metering records from the `AWSMarketplaceMeteringRecords` table using the PendingMeteringRecordsIndex. +All of the pending records are aggregated based on the customerIdentifier and dimension name, and sent to the SQSMetering queue. +The records in the `AWSMarketplaceMeteringRecords` table are expected to be inserted programaticaly by your SaaS aplication. In this case you will have to give permissions to the service in charge of collecting usage data in your existing SaaS product to be able to write to `AWSMarketplaceMeteringRecords` table. + +The lambda function `metering-sqs.js` is sending all of the queued metering records to the AWS marketplace Metering API. +After every call to the `batchMeterUsage` endpoint the rows are updated in the AWSMarketplaceMeteringRecords table, with the response returned from the Metering Service, which can be found in the `metering_response` field. If the request was unsuccessful the metering_failed value with be set to true and you will have to investigate the issue the error will be also stored in the `metering_response` field. + +The new records in the AWSMarketplaceMeteringRecords table should be stored in the following format: + + +```javascript +{ + "create_timestamp": 113123, + "customerIdentifier": "ifAPi5AcF3", + "dimension_usage": [ + { + "dimension": "users", + "value": 3 + }, + { + "dimension": "admin_users", + "value": 1 + } + ], + "metering_pending": "true" +} +``` + +Where the create_timestamp is the sort key and customerIdentifier is the partition key, and they are both forming the Primary key. +The AWSMarketplaceMeteringRecords table + +After the record is submitted to AWS Marketplace API, it will be updated and I.E. will look like this: + +```javascript +{ + "create_timestamp": 113123, + "customerIdentifier": "ifAPi5AcF3", + "dimension_usage": [ + { + "dimension": "admin_users", + "value": 3 + } + ], + "metering_failed": false, + "metering_response": "{\"Results\":[{\"UsageRecord\":{\"Timestamp\":\"2020-06-24T04:04:53.776Z\",\"CustomerIdentifier\":\"ifAPi5AcF3\",\"Dimension\":\"admin_users\",\"Quantity\":3},\"MeteringRecordId\":\"35155d37-56cb-423f-8554-5c4f3e3ff56d\",\"Status\":\"Success\"}],\"UnprocessedRecords\":[]}" +} +``` + +## Deploy the sample application + +The Serverless Application Model Command Line Interface (SAM CLI) is an extension of the AWS CLI that adds functionality for building and testing Lambda applications. It uses Docker to run your functions in an Amazon Linux environment that matches Lambda. It can also emulate your application's build environment and API. + +To use the SAM CLI, you need the following tools. + +* SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +* Node.js - [Install Node.js 10](https://nodejs.org/en/), including the NPM package management tool. +* Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) + +To build and deploy your application for the first time, run the following in your shell: + +```bash + +#Replace all `` with their `actual values` (e.g. `` with `My Cool Project`). + +sam build +sam package --output-template-file packaged.yaml --s3-bucket + +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= \ + +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; +NewSubscribersTableName | Use custome name for the New Subscribers Table; Default value: AWSMarketplaceSubscribers +AWSMarketplaceMeteringRecordsTableName | Use custome 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 +SubscriptionSNSTopic | SNS topic ARN provided from AWS Marketplace +CreateRegistrationWebPage | true or false; Default value: true +MarketplaceTechAdminEmail | Email to be notified on changes requring action + + +### Diagram of created resources + +Based on the value of the **TypeOfSaaSListing** parameter different set of resources will be created. + +In the case of *contracts_with_subscription* all of the resources depicted on the diagram below will be created. + +In the case of a *contracts*, the resources market with orange circless will not be created. + +In the case of a *subscriptions* the resources market with purple circless will not be created. + +The landing page is optional. Use the CreateRegistrationWebPage parameter. + + +![](misc/Serverless-MP.png) + + +## Cleanup + +To delete the sample application that you created, use the AWS CLI. Assuming you used your project name for the stack name, you can run the following: + +```bash +aws cloudformation delete-stack --stack-name app +``` -* Change the title in this README -* Edit your repository description on GitHub ## Security @@ -13,5 +200,4 @@ See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more inform ## License -This library is licensed under the MIT-0 License. See the LICENSE file. - +This library is licensed under the MIT-0 License. See the LICENSE file. \ No newline at end of file diff --git a/misc/Onbording-CF.png b/misc/Onbording-CF.png new file mode 100644 index 0000000..ae5f5f5 Binary files /dev/null and b/misc/Onbording-CF.png differ diff --git a/misc/Onbording.drawio b/misc/Onbording.drawio new file mode 100644 index 0000000..329b114 --- /dev/null +++ b/misc/Onbording.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/misc/Serverless-MP(17).drawio b/misc/Serverless-MP(17).drawio new file mode 100644 index 0000000..bd2d0aa --- /dev/null +++ b/misc/Serverless-MP(17).drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/misc/Serverless-MP.png b/misc/Serverless-MP.png new file mode 100644 index 0000000..8872625 Binary files /dev/null and b/misc/Serverless-MP.png differ diff --git a/misc/banner.png b/misc/banner.png new file mode 100644 index 0000000..e058189 Binary files /dev/null and b/misc/banner.png differ diff --git a/misc/onbording.gif b/misc/onbording.gif new file mode 100644 index 0000000..a058550 Binary files /dev/null and b/misc/onbording.gif differ diff --git a/src/entitlement-sqs.js b/src/entitlement-sqs.js new file mode 100644 index 0000000..6858037 --- /dev/null +++ b/src/entitlement-sqs.js @@ -0,0 +1,53 @@ +const AWS = require('aws-sdk'); + +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); +const marketplaceEntitlementService = new AWS.MarketplaceEntitlementService({ apiVersion: '2017-01-11', region: 'us-east-1' }); +const { NewSubscribersTableName: newSubscribersTableName } = process.env; + +exports.handler = async (event) => { + await Promise.all(event.Records.map(async (record) => { + const { body } = record; + let { Message: message } = JSON.parse(body); + + if (typeof message === 'string' || message instanceof String) { + message = JSON.parse(message); + } + + if (message.action === 'entitlement-updated') { + const entitlementParams = { + ProductCode: message['product-code'], + Filter: { + CUSTOMER_IDENTIFIER: [message['customer-identifier']], + }, + }; + + const entitlementsResponse = await marketplaceEntitlementService.getEntitlements(entitlementParams).promise(); + + console.log('entitlementsResponse', entitlementsResponse); + + const isExpired = new Date(entitlementsResponse.Entitlements[0].ExpirationDate) < new Date(); + + const dynamoDbParams = { + TableName: newSubscribersTableName, + Key: { + customerIdentifier: { S: message['customer-identifier'] }, + }, + UpdateExpression: 'set entitlement = :e, successfully_subscribed = :ss, subscription_expired = :se', + ExpressionAttributeValues: { + ':e': { S: JSON.stringify(entitlementsResponse) }, + ':ss': { BOOL: true }, + ':se': { BOOL: isExpired }, + }, + ReturnValues: 'UPDATED_NEW', + }; + + await dynamodb.updateItem(dynamoDbParams).promise(); + } else { + console.error('Unhandled action'); + 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 new file mode 100644 index 0000000..5eb9935 --- /dev/null +++ b/src/grant-revoke-access-to-product.js @@ -0,0 +1,65 @@ +const AWS = require('aws-sdk'); + +const SNS = new AWS.SNS({ apiVersion: '2010-03-31' }); +const { SupportSNSArn: TopicArn } = process.env; + + +exports.dynamodbStreamHandler = async (event) => { + await Promise.all(event.Records.map(async (record) => { + const oldImage = AWS.DynamoDB.Converter.unmarshall(record.dynamodb.OldImage); + const newImage = AWS.DynamoDB.Converter.unmarshall(record.dynamodb.NewImage); + + // eslint-disable-next-line no-console + console.log(`DynamoDb record updated! OldImage: ${JSON.stringify(oldImage)} | NewImage: ${JSON.stringify(newImage)}`); + + + /* + successfully_subscribed is set true: + - for SaaS Contracts: after reciving the entitelement in entitlement-sqs.js for the first time + - for SaaS Subscriptions: after reciving the subscribe-success message in subscription-sqs.js + + subscription_expired is set to true: + - for SaaS Contracts: after detecting expired entitelement in entitlement-sqs.js + - for SaaS Subscriptions: after reciving the unsubscribe-success message in subscription-sqs.js + */ + const grantAccess = newImage.successfully_subscribed === true + && oldImage.successfully_subscribed !== true; + + const revokeAccess = newImage.subscription_expired === true + && !oldImage.subscription_expired; + + let entitelementUpdated = false; + + if (newImage.entitlement && oldImage.entitlement && (newImage.entitlement !== oldImage.entitlement)) { + entitelementUpdated = true; + } + + if (grantAccess || revokeAccess || entitelementUpdated) { + let message = ''; + let subject = ''; + + + if (grantAccess) { + subject = 'New AWS Marketplace Subscriber'; + message = `Grant access to new SaaS customer: ${JSON.stringify(newImage)}`; + } else if (revokeAccess) { + subject = 'AWS Marketplace customer end of subscription'; + message = `Revoke access to SaaS customer: ${JSON.stringify(newImage)}`; + } else if (entitelementUpdated) { + subject = 'AWS Marketplace customer change of subscription'; + message = `New entitlement for customer: ${JSON.stringify(newImage)}`; + } + + const SNSparams = { + TopicArn, + Subject: subject, + Message: message, + }; + + await SNS.publish(SNSparams).promise(); + } + })); + + + return {}; +}; diff --git a/src/lambda-edge/edege-redirect.js b/src/lambda-edge/edege-redirect.js new file mode 100644 index 0000000..c6c465f --- /dev/null +++ b/src/lambda-edge/edege-redirect.js @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..3241313 --- /dev/null +++ b/src/lambda-edge/package.json @@ -0,0 +1,4 @@ +{ + "name": "lambda-edge-function", + "version": "1.0.0" +} diff --git a/src/metering-hourly-job.js b/src/metering-hourly-job.js new file mode 100644 index 0000000..d0d4910 --- /dev/null +++ b/src/metering-hourly-job.js @@ -0,0 +1,66 @@ +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 { SQSMeteringRecrodsUrl: QueueUrl, AWSMarketplaceMeteringRecordsTableName } = process.env; + + +async function asyncForEach(array, callback) { + for (let index = 0; index < array.length; index++) { + await callback(array[index], index, array) + } +} + +const addUpDimensions = (objectArray) => Object.values(objectArray.reduce((accumulator, currentValue) => ( + (accumulator[currentValue.dimension] + ? (accumulator[currentValue.dimension].value += currentValue.value) + : accumulator[currentValue.dimension] = { ...currentValue } + ), accumulator), {})); + + +exports.job = async () => { + const params = { + TableName: AWSMarketplaceMeteringRecordsTableName, + IndexName: 'PendingMeteringRecordsIndex', + KeyConditionExpression: 'metering_pending = :b', + ExpressionAttributeValues: { + ':b': { S: 'true' }, + }, + }; + + const result = await dynamodb.query(params).promise(); + + const items = result.Items.map((i) => AWS.DynamoDB.Converter.unmarshall(i)); + const hashMap = {}; + + items.map((item) => { + const { customerIdentifier } = item; + + if (hashMap[customerIdentifier]) { + hashMap[customerIdentifier].create_timestamps.push(item.create_timestamp); + hashMap[customerIdentifier].dimension_usage = addUpDimensions([...hashMap[customerIdentifier].dimension_usage, ...item.dimension_usage]); + } else { + hashMap[customerIdentifier] = item; + hashMap[customerIdentifier].create_timestamps = [item.create_timestamp]; + delete hashMap[customerIdentifier].create_timestamp; + } + }); + + await asyncForEach(Object.keys(hashMap), async (hash) => { + const SQSParams = { + MessageBody: JSON.stringify(hashMap[hash]), + MessageGroupId: hash, + QueueUrl, + }; + + try { + await sqs.sendMessage(SQSParams).promise(); + console.log(`Records submitted to queue: ${JSON.stringify(hashMap[hash])}`); + } catch (error) { + console.error(error, error.stack); + } + }); + + return true; +}; diff --git a/src/metering-sqs.js b/src/metering-sqs.js new file mode 100644 index 0000000..5f33750 --- /dev/null +++ b/src/metering-sqs.js @@ -0,0 +1,67 @@ +const AWS = require('aws-sdk'); + +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: '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 = []; + body.dimension_usage.map((r) => UsageRecords.push( + { + CustomerIdentifier: body.customerIdentifier, + Dimension: r.dimension, + Quantity: r.value, + Timestamp: timestmpNow, + }, + )); + + const batchMeteringParams = { + ProductCode, + UsageRecords, + }; + + let meteringResponse = ''; + let meteringFailed = false; + try { + meteringResponse = await marketplacemetering.batchMeterUsage(batchMeteringParams).promise(); + if(meteringResponse.Results.find(r => r.Status !== 'Success')){ + meteringFailed = true; + } + } catch (error) { + meteringResponse = JSON.stringify(error); + meteringFailed = true; + } + + + await Promise.all(body.create_timestamps.map(async (ts) => { + const dynamoDbParams = { + TableName: AWSMarketplaceMeteringRecordsTableName, + Key: { + customerIdentifier: { S: body.customerIdentifier }, + create_timestamp: { N: `${ts}` }, + }, + UpdateExpression: 'set metering_response = :x, metering_failed = :mf remove metering_pending', + ExpressionAttributeValues: { + ':x': { S: JSON.stringify(meteringResponse) }, + ':mf': { BOOL: meteringFailed }, + }, + ReturnValues: 'UPDATED_NEW', + }; + + await dynamodb.updateItem(dynamoDbParams).promise(); + + })); + + })); + + + return {}; +}; diff --git a/src/package-lock.json b/src/package-lock.json new file mode 100644 index 0000000..1dcf4b4 --- /dev/null +++ b/src/package-lock.json @@ -0,0 +1,102 @@ +{ + "name": "hello_world", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "aws-sdk": { + "version": "2.668.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.668.0.tgz", + "integrity": "sha512-mmZJmeenNM9hRR4k+JAStBhYFym2+VCPTRWv0Vn2oqqXIaIaNVdNf9xag/WMG8b8M80R3XXfVHKmDPST0/EfHA==", + "requires": { + "buffer": "4.9.1", + "events": "1.1.1", + "ieee754": "1.1.13", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + } + }, + "base64-js": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", + "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "ieee754": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", + "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + } + } +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 0000000..3472af3 --- /dev/null +++ b/src/package.json @@ -0,0 +1,11 @@ +{ + "name": "aws-marketplace-saas-serverless-integration", + "version": "1.0.0", + "description": "Sample integration for SaaS products with AWS Marketplace", + "repository": "https://github.com/orgs/aws-samples/aws-marketplace-saas-serverless-integration", + "author": "Martin Gjoshevski ", + "license": "MIT-0", + "dependencies": { + "aws-sdk": "^2.7" + } +} diff --git a/src/register-new-subscriber.js b/src/register-new-subscriber.js new file mode 100644 index 0000000..1a051cc --- /dev/null +++ b/src/register-new-subscriber.js @@ -0,0 +1,80 @@ +const AWS = require('aws-sdk'); + +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 } = process.env; + +const lambdaResponse = (statusCode, body) => ({ + statusCode, + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'OPTIONS,POST', + }, + + body: JSON.stringify(body), +}); + +exports.registerNewSubscriber = async (event) => { + const { + regToken, companyName, contactPerson, contactPhone, contactEmail, + } = JSON.parse(event.body); + + // Validate the request + if (regToken && companyName && contactPerson && contactPhone && contactEmail) { + try { + // Call resolveCustomer to validate the subscirber + const resolveCustomerParams = { + RegistrationToken: regToken, + }; + + const resolveCustomerResponse = await marketplacemetering + .resolveCustomer(resolveCustomerParams) + .promise(); + + // Store new subscirber data in dynmoDb + const { CustomerIdentifier, ProductCode } = resolveCustomerResponse; + + const datetime = new Date().getTime().toString(); + + const dynamoDbParams = { + TableName: newSubscribersTableName, + Item: { + companyName: { S: companyName }, + contactPerson: { S: contactPerson }, + contactPhone: { S: contactPhone }, + contactEmail: { S: contactEmail }, + customerIdentifier: { S: CustomerIdentifier }, + productCode: { S: ProductCode }, + created: { S: datetime }, + }, + }; + + await dynamodb.putItem(dynamoDbParams).promise(); + + // Only for SaaS Contracts, check entitelment + if (entitlementQueueUrl) { + const SQSParams = { + MessageBody: `{ + "Type": "Notification", + "Message" : { + "action" : "entitlement-updated", + "customer-identifier": "${CustomerIdentifier}", + "product-code" : "${ProductCode}" + } + }`, + QueueUrl: entitlementQueueUrl, + }; + + await sqs.sendMessage(SQSParams).promise(); + } + + return lambdaResponse(200, 'Thank you for registering. Please check your email for a confirmation!'); + } catch (error) { + console.error(error); + return lambdaResponse(400, 'Registration data not valid. Please try again, or contact support!'); + } + } else { + return lambdaResponse(400, 'Request no valid'); + } +}; diff --git a/src/subscription-sqs.js b/src/subscription-sqs.js new file mode 100644 index 0000000..39bae04 --- /dev/null +++ b/src/subscription-sqs.js @@ -0,0 +1,59 @@ +const AWS = require('aws-sdk'); + +const dynamodb = new AWS.DynamoDB({ apiVersion: '2012-08-10', region: 'us-east-1' }); +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) => { + const { body } = record; + let { Message: message } = JSON.parse(body); + + if (typeof message === 'string' || message instanceof String) { + message = JSON.parse(message); + } + + let successfullySubscribed = false; + let subscriptionExpired = false; + + if (message.action === 'subscribe-success') { + successfullySubscribed = true; + } else if (message.action === 'unsubscribe-pending') { + const SNSparams = { + TopicArn, + Subject: 'unsubscribe pending', + Message: `unsubscribe pending: ${JSON.stringify(message)}`, + }; + + await SNS.publish(SNSparams).promise(); + } else if (message.action === 'subscribe-fail') { + const SNSparams = { + TopicArn, + Subject: 'AWS Marketplace Subscription failed', + Message: `Subscription failed: ${JSON.stringify(message)}`, + }; + + await SNS.publish(SNSparams).promise(); + } else if (message.action === 'unsubscribe-success') { + subscriptionExpired = true; + } else { + console.error('Unhandled action'); + throw new Error(`Unhandled action - msg: ${JSON.stringify(record)}`); + } + + const dynamoDbParams = { + TableName: newSubscribersTableName, + Key: { + customerIdentifier: { S: message['customer-identifier'] }, + }, + UpdateExpression: 'set successfully_subscribed = :ss, subscription_expired = :se', + ExpressionAttributeValues: { + ':ss': { BOOL: successfullySubscribed }, + ':se': { BOOL: subscriptionExpired }, + }, + ReturnValues: 'UPDATED_NEW', + }; + + await dynamodb.updateItem(dynamoDbParams).promise(); + })); +}; diff --git a/template.yaml b/template.yaml new file mode 100644 index 0000000..648a4ae --- /dev/null +++ b/template.yaml @@ -0,0 +1,392 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: > + app + + Sample SAM Template for app + +Globals: + Function: + Timeout: 15 + Api: + Cors: + AllowMethods: "'POST,OPTIONS'" + AllowHeaders: "'*'" + AllowOrigin: "'*'" + AllowCredentials: "'*'" + +Parameters: + WebsiteS3BucketName: + Type: String + Default: "" + + NewSubscribersTableName: + Type: String + AllowedPattern: ".*" + Default: "AWSMarketplaceSubscribers" + + AWSMarketplaceMeteringRecordsTableName: + Type: String + AllowedPattern: ".*" + Default: "AWSMarketplaceMeteringRecords" + + TypeOfSaaSListing: + Type: String + Default: contracts_with_subscription + AllowedValues: + - contracts_with_subscription + - contracts + - subscriptions + + ProductCode: + Type: String + AllowedPattern: ".*" + + MarketplaceTechAdminEmail: + Type: String + AllowedPattern: ".*" + + EntitlementSNSTopic: + Type: String + Default: "" + + SubscriptionSNSTopic: + Type: String + + CreateRegistrationWebPage: + Default: true + Type: String + AllowedValues: [true, false] + +Conditions: + CreateEntitlementLogic: + Fn::Or: + - !Equals [!Ref TypeOfSaaSListing, contracts_with_subscription] + - !Equals [!Ref TypeOfSaaSListing, contracts] + + CreateSubscriptionLogic: + Fn::Or: + - !Equals [!Ref TypeOfSaaSListing, contracts_with_subscription] + - !Equals [!Ref TypeOfSaaSListing, subscriptions] + + CreateWeb: !Equals [!Ref CreateRegistrationWebPage, 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 WebsiteS3Bucket.DomainName + IncludeCookies: false + Prefix: "access-logs" + + WebsiteS3Bucket: + Type: AWS::S3::Bucket + Condition: CreateWeb + Properties: + BucketName: !Ref WebsiteS3BucketName + + S3BucketPolicy: + Type: AWS::S3::BucketPolicy + Condition: CreateWeb + 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 + Properties: + Runtime: nodejs12.x + CodeUri: src/lambda-edge/ + Handler: edege-redirect.lambdaHandler + Timeout: 5 + AutoPublishAlias: live + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: "Allow" + Action: "sts:AssumeRole" + Principal: + Service: + - "lambda.amazonaws.com" + - "edgelambda.amazonaws.com" + + AWSMarketplaceMeteringRecords: + Type: AWS::DynamoDB::Table + Condition: CreateSubscriptionLogic + Properties: + AttributeDefinitions: + - AttributeName: "customerIdentifier" + AttributeType: "S" + - AttributeName: "create_timestamp" + AttributeType: "N" + - AttributeName: "metering_pending" + AttributeType: "S" + + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: "customerIdentifier" + KeyType: "HASH" + - AttributeName: "create_timestamp" + KeyType: "RANGE" + GlobalSecondaryIndexes: + - IndexName: PendingMeteringRecordsIndex + KeySchema: + - AttributeName: "metering_pending" + KeyType: "HASH" + Projection: + ProjectionType: ALL + TableName: !Ref AWSMarketplaceMeteringRecordsTableName + + AWSMarketplaceSubscribers: + Type: AWS::DynamoDB::Table + Properties: + AttributeDefinitions: + - AttributeName: "customerIdentifier" + AttributeType: "S" + BillingMode: PAY_PER_REQUEST + KeySchema: + - AttributeName: "customerIdentifier" + KeyType: "HASH" + TableName: !Ref NewSubscribersTableName + StreamSpecification: + StreamViewType: NEW_AND_OLD_IMAGES + + RegisterNewMarketplaceCustomer: + Type: AWS::Serverless::Function + Properties: + CodeUri: src/ + Handler: register-new-subscriber.registerNewSubscriber + Runtime: nodejs12.x + Environment: + Variables: + NewSubscribersTableName: !Ref NewSubscribersTableName + EntitlementQueueUrl: !If [CreateSubscriptionLogic, !Ref EntitlementSQSQueue, '' ] + Policies: + - DynamoDBWritePolicy: + TableName: !Ref NewSubscribersTableName + - Statement: + - Sid: AWSMarketplaceResolveCustomer + Effect: Allow + Action: + - aws-marketplace:ResolveCustomer + Resource: "*" + - Statement: + - Sid: SQSPolice + Effect: Allow + Action: + - sqs:SendMessage + Resource: !GetAtt EntitlementSQSQueue.Arn + + Events: + RegisterCustomer: + Type: Api + Properties: + Path: /subscriber + Method: post + + EntitlementSQSQueue: + Type: AWS::SQS::Queue + Condition: CreateSubscriptionLogic + + EntitlementSQSHandler: + Type: AWS::Serverless::Function + Condition: CreateEntitlementLogic + Properties: + CodeUri: src + Handler: entitlement-sqs.handler + Runtime: nodejs12.x + Environment: + Variables: + NewSubscribersTableName: !Ref NewSubscribersTableName + Policies: + - DynamoDBWritePolicy: + TableName: !Ref NewSubscribersTableName + - SQSSendMessagePolicy: + QueueName: !GetAtt SQSMeteringRecrods.Arn + - Statement: + - Sid: AWSMarketplaceEntitlements + Effect: Allow + Action: + - aws-marketplace:GetEntitlements + Resource: "*" + Events: + MySQSEvent: + Type: SNS + Properties: + Topic: !Ref EntitlementSNSTopic + SqsSubscription: + BatchSize: 1 + QueueArn: !GetAtt EntitlementSQSQueue.Arn + QueueUrl: !Ref EntitlementSQSQueue + + SubscriptionSQSHandler: + Type: AWS::Serverless::Function + Condition: CreateSubscriptionLogic + Properties: + CodeUri: src + Handler: subscription-sqs.SQSHandler + Runtime: nodejs12.x + Environment: + Variables: + NewSubscribersTableName: !Ref NewSubscribersTableName + SupportSNSArn: !Ref SupportSNSTopic + Policies: + - DynamoDBWritePolicy: + TableName: !Ref NewSubscribersTableName + - Statement: + - Sid: SNSPublish + Effect: Allow + Action: + - sns:Publish + Resource: !Ref SupportSNSTopic + Events: + MySQSEvent: + Type: SNS + Properties: + Topic: !Ref SubscriptionSNSTopic + SqsSubscription: true + + SupportSNSTopic: + Type: AWS::SNS::Topic + Properties: + Subscription: + - Endpoint: !Ref MarketplaceTechAdminEmail + Protocol: email + + GrantOrRevokeAccess: + Type: AWS::Serverless::Function + Properties: + CodeUri: src + Handler: grant-revoke-access-to-product.dynamodbStreamHandler + Runtime: nodejs12.x + Environment: + Variables: + SupportSNSArn: !Ref SupportSNSTopic + Policies: + - AWSLambdaDynamoDBExecutionRole + - Statement: + - Sid: SNSPublish + Effect: Allow + Action: + - sns:Publish + Resource: !Ref SupportSNSTopic + Events: + Stream: + Type: DynamoDB + Properties: + Stream: !GetAtt AWSMarketplaceSubscribers.StreamArn + BatchSize: 1 + StartingPosition: TRIM_HORIZON + + Hourly: + Type: AWS::Serverless::Function + Condition: CreateSubscriptionLogic + Properties: + CodeUri: src + Handler: metering-hourly-job.job + Runtime: nodejs12.x + Environment: + Variables: + SQSMeteringRecrodsUrl: !Ref SQSMeteringRecrods + AWSMarketplaceMeteringRecordsTableName: !Ref AWSMarketplaceMeteringRecordsTableName + Policies: + - DynamoDBReadPolicy: + TableName: !Ref AWSMarketplaceMeteringRecordsTableName + - SQSSendMessagePolicy: + QueueName: !GetAtt SQSMeteringRecrods.QueueName + Events: + CWSchedule: + Type: Schedule + Properties: + Schedule: "rate(1 hour)" + Name: !Join [ "-", [MeteringSchedule, !Ref AWS::StackName]] + Description: SaaS Metering + Enabled: TRUE + + SQSMeteringRecrods: + Type: AWS::SQS::Queue + Properties: + ContentBasedDeduplication: true + FifoQueue: true + MessageRetentionPeriod: 3000 + Condition: CreateSubscriptionLogic + + MeteringSQSHandler: + Type: AWS::Serverless::Function + Condition: CreateSubscriptionLogic + Properties: + CodeUri: src + Handler: metering-sqs.handler + Runtime: nodejs12.x + Environment: + Variables: + ProductCode: !Ref ProductCode + AWSMarketplaceMeteringRecordsTableName: !Ref AWSMarketplaceMeteringRecordsTableName + Policies: + - DynamoDBWritePolicy: + TableName: !Ref AWSMarketplaceMeteringRecordsTableName + - Statement: + - Sid: AWSMarketplaceMetering + Effect: Allow + Action: + - aws-marketplace:BatchMeterUsage + Resource: "*" + Events: + MySQSEvent: + Type: SQS + Properties: + Queue: !GetAtt SQSMeteringRecrods.Arn + BatchSize: 1 \ No newline at end of file diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000..d17b170 Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..f59dd3e --- /dev/null +++ b/web/index.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + Registration page + + + + + +
+
+ + +
+ + + + + + + + + + + + \ No newline at end of file diff --git a/web/logo.png b/web/logo.png new file mode 100644 index 0000000..29b6dbc Binary files /dev/null and b/web/logo.png differ diff --git a/web/script.js b/web/script.js new file mode 100644 index 0000000..0f6bebe --- /dev/null +++ b/web/script.js @@ -0,0 +1,67 @@ +const baseUrl = 'https://x6ct31uisg.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) => { + const html = ` + `; + + document.querySelector('#alert').innerHTML += html; +}; + +const formToJSON = (elements) => [].reduce.call(elements, (data, element) => { + data[element.name] = element.value; + return data; +}, {}); + +const getUrlParameter = (name) => { + name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); + const regex = new RegExp(`[\\?&]${name}=([^&#]*)`); + const results = regex.exec(location.search); + return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); +}; + +const handleFormSubmit = (event) => { + event.preventDefault(); + + const postUrl = `${baseUrl}subscriber`; + const regToken = getUrlParameter('x-amzn-marketplace-token'); + + if (!regToken) { + showAlert('danger', + 'Registration Token Missing. Please go to AWS Marketplace and folow the instructions to set up your account!'); + } else { + const data = formToJSON(form.elements); + data.regToken = regToken; + + + const xhr = new XMLHttpRequest(); + + xhr.open('POST', postUrl, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.send(JSON.stringify(data)); + + xhr.onreadystatechange = () => { + if (xhr.readyState == XMLHttpRequest.DONE) { + showAlert('primary', xhr.responseText); + console.log(JSON.stringify(xhr.responseText)); + } + }; + } +}; + + +form.addEventListener('submit', handleFormSubmit); + +const regToken = getUrlParameter('x-amzn-marketplace-token'); +if (!regToken) { + showAlert('danger', 'Registration Token Missing. Please go to AWS Marketplace and folow the instructions to set up your account!'); +} + +if (!baseUrl) { + showAlert('danger', 'Please update the baseUrl'); +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..144df43 --- /dev/null +++ b/web/style.css @@ -0,0 +1,59 @@ +html, +body { + height: 100%; +} + +body { + display: -ms-flexbox; + display: flex; + -ms-flex-align: center; + align-items: center; + padding-top: 40px; + padding-bottom: 40px; + background-color: #f5f5f5; +} + +.form-signin { + width: 100%; + max-width: 330px; + padding: 15px; + margin: auto; +} +.form-signin .checkbox { + font-weight: 400; +} +.form-signin .form-control { + position: relative; + box-sizing: border-box; + height: auto; + padding: 10px; + font-size: 16px; + margin-top:5px; +} +.form-signin .form-control:focus { + z-index: 2; +} +.form-signin input[type="email"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-signin input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.bd-placeholder-img { + font-size: 1.125rem; + text-anchor: middle; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + @media (min-width: 768px) { + .bd-placeholder-img-lg { + font-size: 3.5rem; + } + }