Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed .DS_Store
Binary file not shown.
171 changes: 97 additions & 74 deletions DEVELOPER_GUIDE.md

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,32 @@
**[Developer Documentation](DEVELOPER_GUIDE.md)**

## Introduction
Organisations are moving to the SaaS (Software-as-a-service) delivery model to achieve optimized cost, operational efficiency and overall agility in their software business. SaaS helps to onboard their customers (tenants) into a centrally hosted version of the solution, and manage them via a single pane of glass. These SaaS solutions allow the underneath infrastructure components to be shared across tenants, while demanding mechanisms that can implement the multi-tenancy in the architecture to preserve overall security, performance and other non-functional requirements demanded by the use-case. Often, these strategies and their implementation heavily depend on the underneath technologies and AWS managed services that are being used.
Organisations are moving to the SaaS (Software-as-a-service) delivery model to achieve optimized cost, operational efficiency and overall agility in their software business. SaaS helps to onboard their customers (tenants) into a centrally hosted version of the solution, and manage them via a single pane of glass. These SaaS solutions allow the underneath infrastructure components to be shared across tenants, while demanding mechanisms that can implement the multi-tenancy in the architecture to preserve overall security, performance and other non-functional requirements demanded by the use-case. Often, these strategies and their implementation heavily depend on the underneath technologies and AWS managed services that are being used.

This github solution provides code samples, configurations and best practices that help to implement multi-tenant SaaS reference architecture leveraging Amazon Elastic Container Service (ECS).
This github solution provides code samples, configurations and best practices that help to implement multi-tenant SaaS reference architecture leveraging Amazon Elastic Container Service (ECS).

The objective here is to dive deeper into design principals and implementation details in building ECS SaaS reference solution covering necessary technical aspects. We will discuss SaaS control plane functionalities with shared services such as tenant onboarding, user management, admin portals, along with the SaaS application plane capabilities such as ECS compute isolation strategies, request routing at scale, service discovery, storage isolation patterns, API throttling and usage plans, and different ways to ensure security and scalability.
The objective here is to dive deeper into design principals and implementation details in building ECS SaaS reference solution covering necessary technical aspects. We will discuss SaaS control plane functionalities with shared services such as tenant onboarding, user management, admin portals, along with the SaaS application plane capabilities such as ECS compute isolation strategies, request routing at scale, service discovery, storage isolation patterns, API throttling and usage plans, and different ways to ensure security and scalability.

## ECS SaaS Reference Solution Overview
## ECS SaaS Reference Solution Overview
The following diagram shows the high-level architecture of the solution that outlines the core components of ECS SaaS. It is a tier-based SaaS, and the two tiers represent two different tenant isolation strategies using Amazon ECS. This would help SaaS providers to have a wide range of technical options to model their SaaS solution based on their tiering requirements.

1. Basic Tier: Shared ECS Services across all the tenants (Pool model)
2. Premium Tier: Dedicated ECS Cluster per tenant (Silo model)
2. Advanced Tier : Shared ECS Cluster, dedicated ECS services per tenant (Silo model)
3. Premium Tier: Dedicated ECS Cluster per tenant (Silo model)

<p align="center">
<img src="images/archi-high-level.png" alt="High-level Architecture"/>
Fig 1: ECS SaaS - High-level infrastructure
</p>


This reference architecture adopts the latest [AWS SaaS Builder Toolkit](https://github.com/awslabs/sbt-aws) (SBT) that [AWS SaaS Factory](https://aws.amazon.com/partners/programs/saas-factory) has developed. SBT helps to extend the SaaS control plane services such as tenant onboarding, off-boarding, tenant and user management, billing, etc seamlessly into the solution. It also provides an event-based integration to the ECS application plane that enables bi-directional communication for SaaS operations. Read more about AWS SBT [here](https://github.com/awslabs/sbt-aws/blob/main/docs/public/README.md).
This reference architecture adopts the latest [AWS SaaS Builder Toolkit](https://github.com/awslabs/sbt-aws) (SBT) that [AWS SaaS Factory](https://aws.amazon.com/partners/programs/saas-factory) has developed. SBT helps to extend the SaaS control plane services such as tenant onboarding, off-boarding, tenant and user management, billing, etc seamlessly into the solution. It also provides an event-based integration to the ECS application plane that enables bi-directional communication for SaaS operations. Read more about AWS SBT [here](https://github.com/awslabs/sbt-aws/blob/main/docs/public/README.md).


## Pre-requisites
This solution can be deployed via an [AWS Cloud9](https://aws.amazon.com/pm/cloud9/) environment on your AWS account, or directly from your laptop.
This solution can be deployed via an [AWS Cloud9](https://aws.amazon.com/pm/cloud9/) environment on your AWS account, or directly from your laptop.

If you are using Cloud9, make sure to use `Amazon Linux 2023` AMI for the EC2 with at least t3.large instance size. Also, increase the volume size of the underlying EC2 instance to 50 GB (instead of default 10 GB) using this script `./scripts/resize-cloud9.sh` - This is to make sure that you have enough compute and space to build the solution.
If you are using Cloud9, make sure to use `Amazon Linux 2023` AMI for the EC2 with at least t3.large instance size. Also, increase the volume size of the underlying EC2 instance to 50 GB (instead of default 10 GB) using this script `./scripts/resize-cloud9.sh` - This is to make sure that you have enough compute and space to build the solution.

- This reference architecture uses Python. Make sure you have Python 3.8 or above installed.
- Make sure you have [AWS CLI 2.14](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html) or above installed.
Expand All @@ -40,7 +41,7 @@ If you are using Cloud9, make sure to use `Amazon Linux 2023` AMI for the EC2 wi

## Deployment Steps

To deploy this ECS SaaS reference solution, you can run below commands. Replace the ```admin_email``` with a real email address that will be used to create an admin user in the solution, and to share the admin credentials that allow to perform administrative tasks such as onboarding new tenants.
To deploy this ECS SaaS reference solution, you can run below commands. Replace the ```admin_email``` with a real email address that will be used to create an admin user in the solution, and to share the admin credentials that allow to perform administrative tasks such as onboarding new tenants.


```bash
Expand Down
Binary file removed client/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
title="tierSelect"
>
<option value="BASIC">Basic</option>
<option value="ADVANCED">Advanced</option>
<option value="PREMIUM">Premium</option>
</select>
</mat-form-field>
Expand Down
Binary file added images/advanced-tier.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/archi-base-infra.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/archi-high-level.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/basic-tier.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/premium-tier.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/solution-architecture-tiers.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions scripts/cleanup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ for i in $(aws s3 ls | awk '{print $3}' | grep -E "^tenant-update-stack-*|^contr
done


cd ../server/infrastructure
cd ../server
npm install

export CDK_PARAM_SYSTEM_ADMIN_EMAIL="NA"
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="ecs-saas-reference-architecture"
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="saas-reference-architecture-ecs"
export CDK_PARAM_COMMIT_ID="NA"
export CDK_PARAM_REG_API_GATEWAY_URL="NA"
export CDK_PARAM_EVENT_BUS_ARN=arn:aws:service:::resource
Expand Down
6 changes: 3 additions & 3 deletions scripts/deprovision-tenant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ delete_items_if_exists() {
}

# Un deploy the tenant template for premium tier(silo)
if [[ $TIER == "PREMIUM" ]]; then
if [[ $TIER == "PREMIUM" || $TIER == "ADVANCED" ]]; then

STACK_NAME=$(aws dynamodb get-item \
--table-name $TENANT_STACK_MAPPING_TABLE \
Expand All @@ -85,9 +85,9 @@ if [[ $TIER == "PREMIUM" ]]; then

echo "Stack name from $TENANT_STACK_MAPPING_TABLE is $STACK_NAME"
# Clone the ecs reference solution repository
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="ecs-saas-reference-architecture"
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="saas-reference-architecture-ecs"
git clone codecommit://$CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME
cd $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME/server/infrastructure
cd $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME/server

export ECR_REGION=$(aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]')
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
Expand Down
6 changes: 3 additions & 3 deletions scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ fi

# Create CodeCommit repo
REGION=$(aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]') # Region setting
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="ecs-saas-reference-architecture"
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="saas-reference-architecture-ecs"
if ! aws codecommit get-repository --repository-name $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME; then
CREATE_REPO=$(aws codecommit create-repository --repository-name $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME --repository-description "ECS saas reference architecture repository")
echo "$CREATE_REPO"
Expand All @@ -27,7 +27,7 @@ export CDK_PARAM_COMMIT_ID=$(git log --format="%H" -n 1)
aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com 2>/dev/null || echo "ECS Service linked role exists"

# Preprovision basic infrastructure
cd ../server/infrastructure
cd ../server

export ECR_REGION=$(aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]')
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
Expand All @@ -50,6 +50,6 @@ npx cdk deploy --all --require-approval never #--concurrency 10 --asset-parallel

# Get SaaS application url
ADMIN_SITE_URL=$(aws cloudformation describe-stacks --stack-name controlplane-stack --query "Stacks[0].Outputs[?OutputKey=='adminSiteUrl'].OutputValue" --output text)
APP_SITE_URL=$(aws cloudformation describe-stacks --stack-name coreappplane-stack --query "Stacks[0].Outputs[?OutputKey=='appSiteUrl'].OutputValue" --output text)
APP_SITE_URL=$(aws cloudformation describe-stacks --stack-name core-appplane-stack --query "Stacks[0].Outputs[?OutputKey=='appSiteUrl'].OutputValue" --output text)
echo "Admin site url: $ADMIN_SITE_URL"
echo "Application site url: $APP_SITE_URL"
13 changes: 9 additions & 4 deletions scripts/provision-tenant.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ sudo python3 -m pip install git-remote-codecommit
shopt -s nocasematch

# Clone the ecs reference solution repository
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="ecs-saas-reference-architecture"
export CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME="saas-reference-architecture-ecs"
git clone codecommit://$CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME --quiet
cd $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME/server/infrastructure
cd $CDK_PARAM_CODE_COMMIT_REPOSITORY_NAME/server

export ECR_REGION=$(aws ec2 describe-availability-zones --output text --query 'AvailabilityZones[0].[RegionName]')
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
Expand All @@ -39,9 +39,14 @@ API_GATEWAY_URL_OUTPUT_PARAM_NAME="ApiGatewayUrl"
APP_CLIENT_ID_OUTPUT_PARAM_NAME="UserPoolClientId"
BOOTSTRAP_STACK_NAME="shared-infra-stack"

# Deploy the tenant template for premium tier(silo)
if [[ $TIER == "PREMIUM" ]]; then

# Deploy the tenant template for premium && advanced tier(silo)
if [[ $TIER == "PREMIUM" || $TIER == "ADVANCED" ]]; then
STACK_NAME="tenant-template-stack-$CDK_PARAM_TENANT_ID"
if [[ $TIER == "ADVANCED" ]]; then
export CDK_ADV_CLUSTER=$(aws ecs describe-clusters --cluster prod-advanced-${ACCOUNT_ID} | jq -r '.clusters[0].status')
fi

export CDK_PARAM_CONTROL_PLANE_SOURCE='sbt-control-plane-api'
export CDK_PARAM_ONBOARDING_DETAIL_TYPE='Onboarding'
export CDK_PARAM_PROVISIONING_DETAIL_TYPE=$CDK_PARAM_ONBOARDING_DETAIL_TYPE
Expand Down
2 changes: 1 addition & 1 deletion scripts/update-tenants.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export CDK_PARAM_OFFBOARDING_DETAIL_TYPE='Offboarding'
export CDK_PARAM_DEPROVISIONING_DETAIL_TYPE=$CDK_PARAM_OFFBOARDING_DETAIL_TYPE
export CDK_PARAM_PROVISIONING_EVENT_SOURCE="sbt-application-plane-api"

cd server/infrastructure
cd server
npm install

# Define the DynamoDB table name and initial parameters
Expand Down
Binary file removed server/.DS_Store
Binary file not shown.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
import * as cdk from 'aws-cdk-lib';
import { TenantTemplateStack } from '../lib/tenant-template/tenant-template-stack';
import { DestroyPolicySetter } from '../lib/utilities/destroy-policy-setter';
import { CoreAppPlaneStack } from '../lib/bootstrap-template/core-app-plane-stack';
import { CoreAppPlaneStack } from '../lib/bootstrap-template/core-appplane-stack';
import { TenantUpdatePipeline } from '../lib/tenant-template/tenant-update-stack';
import { getEnv } from '../lib/utilities/helper-functions';
import { ControlPlaneStack } from '../lib/bootstrap-template/control-plane-stack';
import { SharedInfraStack } from '../lib/sharedinfra-template/shared-infra-stack';
import { SharedInfraStack } from '../lib/shared-infra/shared-infra-stack';
import { AwsSolutionsChecks } from 'cdk-nag';

const app = new cdk.App();
Expand Down Expand Up @@ -69,6 +69,9 @@ const apiKeyAdvancedTierParameter =
const apiKeyBasicTierParameter =
process.env.CDK_PARAM_API_KEY_BASIC_TIER_PARAMETER || defaultApiKeyBasicTierParameter;
const isPooledDeploy = tenantId == basicId;
//A flag to check whether the Advanced cluster is exist.
//If not exist, value is INACTIVE.
const advancedCluster = process.env.CDK_ADV_CLUSTER || 'INACTIVE';

// parameter names to facilitate sharing api keys
// between the bootstrap template and the tenant template stack(s)
Expand Down Expand Up @@ -103,7 +106,7 @@ const controlPlaneStack = new ControlPlaneStack(app, 'controlplane-stack', {
}
});

const coreAppPlaneStack = new CoreAppPlaneStack(app, 'coreappplane-stack', {
const coreAppPlaneStack = new CoreAppPlaneStack(app, 'core-appplane-stack', {
systemAdminEmail: systemAdminEmail,
regApiGatewayUrl: controlPlaneStack.regApiGatewayUrl,
eventBusArn: controlPlaneStack.eventBusArn,
Expand Down Expand Up @@ -142,6 +145,7 @@ const tenantTemplateStack = new TenantTemplateStack(app, `tenant-template-stack-
tenantMappingTable: coreAppPlaneStack.tenantMappingTable,
commitId: commitId,
tier: tier,
advancedCluster: advancedCluster,
appSiteUrl: coreAppPlaneStack.userInterface.appSiteUrl,
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
Expand Down
File renamed without changes.
Binary file removed server/infrastructure/.DS_Store
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class ControlPlaneStack extends Stack {

this.StaticSite = new StaticSite(this, 'AdminWebUi', {
name: 'AdminSite',
assetDirectory: path.join(__dirname, '../../../../client/AdminWeb/'),
assetDirectory: path.join(__dirname, '../../../client/AdminWeb/'),
production: true,
clientId: this.auth.clientId,
issuer: this.auth.authorizationServer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class CoreAppPlaneStack extends Stack {
}
`)
),
script: fs.readFileSync('../../scripts/provision-tenant.sh', 'utf8'),
script: fs.readFileSync('../scripts/provision-tenant.sh', 'utf8'),
outgoingEvent: DetailType.PROVISION_SUCCESS,
incomingEvent: DetailType.ONBOARDING_REQUEST,

Expand Down Expand Up @@ -96,7 +96,7 @@ export class CoreAppPlaneStack extends Stack {
}
`)
),
script: fs.readFileSync('../../scripts/deprovision-tenant.sh', 'utf8'),
script: fs.readFileSync('../scripts/deprovision-tenant.sh', 'utf8'),
environmentStringVariablesFromIncomingEvent: ['tenantId', 'tier'],
environmentVariablesToOutgoingEvent: ['tenantStatus'],
outgoingEvent: DetailType.DEPROVISION_SUCCESS,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class UserInterface extends Construct {

new StaticSite(this, 'TenantWebUI', {
name: 'AppSite',
assetDirectory: path.join(__dirname, '../../../../client/Application/'),
assetDirectory: path.join(__dirname, '../../../client/Application/'),
production: true,
apiUrl: props.regApiGatewayUrl,
distribution: distro.cloudfrontDistribution,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ export class CoreAppPlaneNag extends Construct {
]
};

const sbtNagPath = '/coreappplane-stack/coreappplane-sbt';
const nagWebPath = '/coreappplane-stack/saas-application-ui/TenantWebUI';
const nagStaticPath = '/coreappplane-stack/saas-application-ui/StaticSiteDistro';
const sbtNagPath = '/core-appplane-stack/coreappplane-sbt';
const nagWebPath = '/core-appplane-stack/saas-application-ui/TenantWebUI';
const nagStaticPath = '/core-appplane-stack/saas-application-ui/StaticSiteDistro';

NagSuppressions.addResourceSuppressionsByPath(
cdk.Stack.of(this),
Expand Down Expand Up @@ -111,7 +111,7 @@ export class CoreAppPlaneNag extends Construct {

NagSuppressions.addResourceSuppressionsByPath(
cdk.Stack.of(this),
'/coreappplane-stack/TenantMappingTable/Resource',
'/core-appplane-stack/TenantMappingTable/Resource',
[
{
id: 'AwsSolutions-DDB3',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { NagSuppressions } from 'cdk-nag';
export interface TenantInfraNagProps {
tenantId: string
isEc2Tier: boolean
tier: string
advancedCluster: string
isRProxy: boolean
}

Expand Down Expand Up @@ -155,10 +157,13 @@ export class TenantInfraNag extends Construct {
);
}

if('advanced' !== props.tier.toLocaleLowerCase() || 'ACTIVE' !== props.advancedCluster ) {
if (props.isEc2Tier) {
NagSuppressions.addResourceSuppressionsByPath(
cdk.Stack.of(this),
[`${nagPath}/ecs-autoscaleG-${props.tenantId}/InstanceRole/Resource`],
[`${nagPath}/EniTrunking/CustomEniTrunkingRole/Resource`,
`${nagPath}/EniTrunking/EC2Role/Resource`,
],
[
{
id: 'AwsSolutions-IAM4',
Expand All @@ -171,7 +176,9 @@ export class TenantInfraNag extends Construct {
);
NagSuppressions.addResourceSuppressionsByPath(
cdk.Stack.of(this),
[`${nagPath}/ecs-autoscaleG-${props.tenantId}/DrainECSHook/Function/ServiceRole/Resource`],
[`${nagPath}/ecs-autoscaleG-${props.tenantId}/DrainECSHook/Function/ServiceRole/Resource`,
`${nagPath}/EniTrunking/CustomEniTrunkingRole/Resource`,
],
[
{
id: 'AwsSolutions-IAM4',
Expand All @@ -194,7 +201,7 @@ export class TenantInfraNag extends Construct {
);
NagSuppressions.addResourceSuppressionsByPath(
cdk.Stack.of(this),
[`${nagPath}/ecs-autoscaleG-${props.tenantId}/InstanceRole/DefaultPolicy/Resource`],
[`${nagPath}/EniTrunking/EC2Role/DefaultPolicy/Resource`],
[
{
id: 'AwsSolutions-IAM5',
Expand Down Expand Up @@ -260,4 +267,5 @@ export class TenantInfraNag extends Construct {
);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def lambda_handler(event, context):
credentials = assumed_role["Credentials"]

tenantPath = tenant_id
if (tenant_tier.upper() != utils.TenantTier.PREMIUM.value.upper()):
if (tenant_tier.upper() == utils.TenantTier.BASIC.value.upper()):
tenantPath = tenant_tier.lower()

logger.info("Tenant Path: " + tenantPath)
Expand Down
Loading