├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── Solution ├── quicksight │ └── modify-quicksight-role.sh ├── saas-app-plane │ ├── product-media-service │ │ ├── cdk │ │ │ ├── .gitignore │ │ │ ├── .npmignore │ │ │ ├── README.md │ │ │ ├── bin │ │ │ │ └── product-media-service-app.ts │ │ │ ├── cdk.context.json │ │ │ ├── cdk.json │ │ │ ├── jest.config.js │ │ │ ├── lib │ │ │ │ ├── application-plane-stack.ts │ │ │ │ └── tenant-provision-stack.ts │ │ │ ├── package-lock.json │ │ │ ├── package.json │ │ │ ├── test │ │ │ │ └── cdk.test.ts │ │ │ ├── tsconfig.json │ │ │ └── utils │ │ │ │ └── cdk-nag-utils.ts │ │ ├── data │ │ │ └── s3_storage_lens_report │ │ │ │ └── s3_storage_lens_report.csv │ │ ├── scripts │ │ │ ├── cleanup.sh │ │ │ ├── deploy-tenant.sh │ │ │ ├── deploy.sh │ │ │ ├── last_listener_rule_priority_base.txt │ │ │ ├── resources │ │ │ │ ├── test-audio.mp4 │ │ │ │ ├── test-image.png │ │ │ │ └── test-text.txt │ │ │ ├── test-aggregator.sh │ │ │ ├── test_harness.sh │ │ │ └── upload_usage_data.sh │ │ └── src │ │ │ ├── product_media.py │ │ │ ├── resources │ │ │ ├── dockerfile │ │ │ └── requirements.txt │ │ │ ├── test.txt │ │ │ └── test │ │ │ └── test_product_media.py │ ├── product-review-service │ │ ├── cdk.out │ │ │ └── synth.lock │ │ ├── cdk │ │ │ ├── .gitignore │ │ │ ├── README.md │ │ │ ├── bin │ │ │ │ └── product-review-app.ts │ │ │ ├── cdk.context.json │ │ │ ├── cdk.json │ │ │ ├── lib │ │ │ │ ├── RdsInitializerConstruct.ts │ │ │ │ ├── RdsPostgresConstruct.ts │ │ │ │ ├── UsageAggregatorConstruct.ts │ │ │ │ ├── application-plane-stack.ts │ │ │ │ ├── ecs-service-stack.ts │ │ │ │ └── tenant-provisioning-stack.ts │ │ │ ├── package-lock.json │ │ │ ├── package.json │ │ │ ├── tsconfig.json │ │ │ └── utils │ │ │ │ └── cdk-nag-utils.ts │ │ ├── scripts │ │ │ ├── deploy-tenant.sh │ │ │ ├── deploy.sh │ │ │ ├── out.json │ │ │ ├── test-aggregator.sh │ │ │ └── test_harness.sh │ │ └── src │ │ │ ├── cdk.out │ │ │ └── synth.lock │ │ │ ├── lambdas-aggregator │ │ │ ├── ecs-usage-aggregator.py │ │ │ ├── i_aggregator.py │ │ │ ├── rds-iops-usage.py │ │ │ ├── rds-performance-insights.py │ │ │ ├── rds-storage-usage.py │ │ │ ├── requirements.txt │ │ │ └── utils │ │ │ │ ├── __init__.py │ │ │ │ ├── aggregator_util.py │ │ │ │ └── utils.py │ │ │ ├── lambdas-rds │ │ │ ├── db-schema.sql │ │ │ ├── rds-tenant-provision.sql │ │ │ ├── rds-tenant.py │ │ │ ├── rds.py │ │ │ └── requirements.txt │ │ │ ├── resources │ │ │ ├── Dockerfile │ │ │ └── requirements.txt │ │ │ └── review-service │ │ │ ├── global_db_pool.py │ │ │ ├── product_review_dal.py │ │ │ ├── product_review_logger.py │ │ │ ├── product_review_model.py │ │ │ ├── product_review_service.py │ │ │ └── utils.py │ ├── product-service │ │ ├── cdk │ │ │ ├── .gitignore │ │ │ ├── .npmignore │ │ │ ├── README.md │ │ │ ├── bin │ │ │ │ └── serverless-saas-app.ts │ │ │ ├── cdk.json │ │ │ ├── interfaces │ │ │ │ └── identity-details.ts │ │ │ ├── jest.config.js │ │ │ ├── lib │ │ │ │ ├── crud-microservice.ts │ │ │ │ ├── lambda-function.ts │ │ │ │ ├── serverless-saas-app-stack.ts │ │ │ │ ├── services.ts │ │ │ │ └── usage-aggregator.ts │ │ │ ├── package-lock.json │ │ │ ├── package.json │ │ │ ├── test │ │ │ │ └── cdk.test.ts │ │ │ ├── tsconfig.json │ │ │ └── utils │ │ │ │ └── cdk-nag-utils.ts │ │ ├── scripts │ │ │ ├── deploy.sh │ │ │ ├── out.json │ │ │ ├── test-aggregator.sh │ │ │ └── test_harness.sh │ │ └── src │ │ │ ├── dal │ │ │ ├── order_service_dal.py │ │ │ └── product_service_dal.py │ │ │ ├── extensions │ │ │ └── telemetry-api │ │ │ │ ├── extensions │ │ │ │ └── telemetry_api_extension │ │ │ │ ├── telemetry_api_extension │ │ │ │ ├── extension.py │ │ │ │ ├── extensions_api_client.py │ │ │ │ ├── requirements.txt │ │ │ │ ├── telemetery_api_client.py │ │ │ │ ├── telemetry_dispatcher.py │ │ │ │ ├── telemetry_http_listener.py │ │ │ │ ├── telemetry_service.py │ │ │ │ ├── test.json │ │ │ │ └── test_req.txt │ │ │ │ └── test │ │ │ │ └── test_telemetry_service.py │ │ │ ├── fine_grained_aggregator.py │ │ │ ├── i_aggregator.py │ │ │ ├── models │ │ │ ├── order_models.py │ │ │ └── product_models.py │ │ │ ├── order_service.py │ │ │ ├── product_service.py │ │ │ ├── requirements.txt │ │ │ ├── test │ │ │ └── test_fine_grained_aggregator.py │ │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── aggregator_util.py │ │ │ ├── auth_manager.py │ │ │ ├── logger.py │ │ │ ├── metrics_manager.py │ │ │ ├── requirements.txt │ │ │ └── utils.py │ ├── provision-tenant.sh │ └── shared-services │ │ ├── cdk │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── bin │ │ │ └── shared-services.ts │ │ ├── cdk.context.json │ │ ├── cdk.json │ │ ├── interfaces │ │ │ └── identity-details.ts │ │ ├── jest.config.js │ │ ├── lib │ │ │ ├── api-gateway.ts │ │ │ ├── athena-output-bucket.ts │ │ │ ├── identity-provider.ts │ │ │ ├── lambda-function.ts │ │ │ ├── saas-tenant-provision.ts │ │ │ ├── services.ts │ │ │ ├── shared-services-stack.ts │ │ │ ├── tenant-usage-bucket.ts │ │ │ └── usage-aggregator.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── test │ │ │ └── cdk.test.ts │ │ ├── tsconfig.json │ │ └── utils │ │ │ └── cdk-nag-utils.ts │ │ ├── scripts │ │ ├── deploy.sh │ │ ├── onboard-tenant.sh │ │ ├── out.json │ │ ├── package-app-plane.sh │ │ ├── replicate_usage_metrics.sh │ │ └── test-aggregator.sh │ │ └── src │ │ ├── __init__.py │ │ ├── abstract_classes │ │ ├── i_aggregator.py │ │ ├── idp_authorizer_abstract_class.py │ │ └── idp_user_management_abstract_class.py │ │ ├── coarse_grained_aggregator.py │ │ ├── cognito │ │ ├── cognito_authorizer.py │ │ ├── cognito_user_management_service.py │ │ └── user_management_util.py │ │ ├── requirements.txt │ │ ├── tenant_authorizer.py │ │ ├── user_management.py │ │ └── utils │ │ ├── __init__.py │ │ ├── aggregator_util.py │ │ ├── auth_manager.py │ │ ├── idp_object_factory.py │ │ ├── logger.py │ │ ├── metrics_manager.py │ │ ├── requirements.txt │ │ └── utils.py └── saas-control-plane │ ├── cdk │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── bin │ │ └── saas-control-plane.ts │ ├── cdk.json │ ├── jest.config.js │ ├── lib │ │ └── saas-control-plane-stack.ts │ ├── package-lock.json │ ├── package.json │ ├── test │ │ └── cdk.test.ts │ ├── tsconfig.json │ └── utils │ │ └── cdk-nag-utils.ts │ ├── package-lock.json │ └── scripts │ └── deploy.sh ├── install.sh ├── quicksight └── modify-quicksight-role.sh ├── saas-app-plane ├── copy-solution.sh ├── product-media-service │ ├── cdk │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── bin │ │ │ └── product-media-service-app.ts │ │ ├── cdk.context.json │ │ ├── cdk.json │ │ ├── jest.config.js │ │ ├── lib │ │ │ ├── application-plane-stack.ts │ │ │ └── tenant-provision-stack.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── test │ │ │ └── cdk.test.ts │ │ ├── tsconfig.json │ │ └── utils │ │ │ └── cdk-nag-utils.ts │ ├── data │ │ ├── s3_storage_lens_report │ │ │ └── s3_storage_lens_report.csv │ │ └── split_cost_allocation_data │ │ │ └── split_cost_allocation_data.csv │ ├── scripts │ │ ├── cleanup.sh │ │ ├── deploy-tenant.sh │ │ ├── deploy.sh │ │ ├── last_listener_rule_priority_base.txt │ │ ├── resources │ │ │ ├── test-audio.mp4 │ │ │ ├── test-image.png │ │ │ └── test-text.txt │ │ ├── test-aggregator.sh │ │ ├── test_harness.sh │ │ └── upload_usage_data.sh │ └── src │ │ ├── product_media.py │ │ ├── resources │ │ ├── dockerfile │ │ └── requirements.txt │ │ ├── test.txt │ │ └── test │ │ └── test_product_media.py ├── product-review-service │ ├── cdk.out │ │ └── synth.lock │ ├── cdk │ │ ├── .gitignore │ │ ├── README.md │ │ ├── bin │ │ │ └── product-review-app.ts │ │ ├── cdk.context.json │ │ ├── cdk.json │ │ ├── lib │ │ │ ├── RdsInitializerConstruct.ts │ │ │ ├── RdsPostgresConstruct.ts │ │ │ ├── UsageAggregatorConstruct.ts │ │ │ ├── application-plane-stack.ts │ │ │ ├── ecs-service-stack.ts │ │ │ └── tenant-provisioning-stack.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── utils │ │ │ └── cdk-nag-utils.ts │ ├── scripts │ │ ├── deploy-tenant.sh │ │ ├── deploy.sh │ │ ├── out.json │ │ ├── test-aggregator.sh │ │ └── test_harness.sh │ └── src │ │ ├── cdk.out │ │ └── synth.lock │ │ ├── lambdas-aggregator │ │ ├── ecs-usage-aggregator.py │ │ ├── i_aggregator.py │ │ ├── rds-iops-usage.py │ │ ├── rds-performance-insights.py │ │ ├── rds-storage-usage.py │ │ ├── requirements.txt │ │ └── utils │ │ │ ├── __init__.py │ │ │ ├── aggregator_util.py │ │ │ └── utils.py │ │ ├── lambdas-rds │ │ ├── db-schema.sql │ │ ├── rds-tenant-provision.sql │ │ ├── rds-tenant.py │ │ ├── rds.py │ │ └── requirements.txt │ │ ├── resources │ │ ├── Dockerfile │ │ └── requirements.txt │ │ └── review-service │ │ ├── global_db_pool.py │ │ ├── product_review_dal.py │ │ ├── product_review_logger.py │ │ ├── product_review_model.py │ │ ├── product_review_service.py │ │ └── utils.py ├── product-service │ ├── cdk │ │ ├── .gitignore │ │ ├── .npmignore │ │ ├── README.md │ │ ├── bin │ │ │ └── serverless-saas-app.ts │ │ ├── cdk.json │ │ ├── interfaces │ │ │ └── identity-details.ts │ │ ├── jest.config.js │ │ ├── lib │ │ │ ├── crud-microservice.ts │ │ │ ├── lambda-function.ts │ │ │ ├── serverless-saas-app-stack.ts │ │ │ ├── services.ts │ │ │ └── usage-aggregator.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── test │ │ │ └── cdk.test.ts │ │ ├── tsconfig.json │ │ └── utils │ │ │ └── cdk-nag-utils.ts │ ├── scripts │ │ ├── deploy.sh │ │ ├── out.json │ │ ├── test-aggregator.sh │ │ └── test_harness.sh │ └── src │ │ ├── dal │ │ ├── order_service_dal.py │ │ └── product_service_dal.py │ │ ├── extensions │ │ └── telemetry-api │ │ │ ├── extensions │ │ │ └── telemetry_api_extension │ │ │ ├── telemetry_api_extension │ │ │ ├── extension.py │ │ │ ├── extensions_api_client.py │ │ │ ├── requirements.txt │ │ │ ├── telemetery_api_client.py │ │ │ ├── telemetry_dispatcher.py │ │ │ ├── telemetry_http_listener.py │ │ │ ├── telemetry_service.py │ │ │ ├── test.json │ │ │ └── test_req.txt │ │ │ └── test │ │ │ └── test_telemetry_service.py │ │ ├── fine_grained_aggregator.py │ │ ├── i_aggregator.py │ │ ├── models │ │ ├── order_models.py │ │ └── product_models.py │ │ ├── order_service.py │ │ ├── product_service.py │ │ ├── requirements.txt │ │ ├── test │ │ └── test_fine_grained_aggregator.py │ │ └── utils │ │ ├── __init__.py │ │ ├── aggregator_util.py │ │ ├── auth_manager.py │ │ ├── logger.py │ │ ├── metrics_manager.py │ │ ├── requirements.txt │ │ └── utils.py ├── provision-tenant.sh └── shared-services │ ├── cdk │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── bin │ │ └── shared-services.ts │ ├── cdk.context.json │ ├── cdk.json │ ├── interfaces │ │ └── identity-details.ts │ ├── jest.config.js │ ├── lib │ │ ├── api-gateway.ts │ │ ├── athena-output-bucket.ts │ │ ├── identity-provider.ts │ │ ├── lambda-function.ts │ │ ├── saas-tenant-provision.ts │ │ ├── services.ts │ │ ├── shared-services-stack.ts │ │ ├── tenant-usage-bucket.ts │ │ └── usage-aggregator.ts │ ├── package-lock.json │ ├── package.json │ ├── test │ │ └── cdk.test.ts │ ├── tsconfig.json │ └── utils │ │ └── cdk-nag-utils.ts │ ├── scripts │ ├── deploy.sh │ ├── onboard-tenant.sh │ ├── out.json │ ├── package-app-plane.sh │ ├── replicate_usage_metrics.sh │ └── test-aggregator.sh │ └── src │ ├── __init__.py │ ├── abstract_classes │ ├── i_aggregator.py │ ├── idp_authorizer_abstract_class.py │ └── idp_user_management_abstract_class.py │ ├── coarse_grained_aggregator.py │ ├── cognito │ ├── cognito_authorizer.py │ ├── cognito_user_management_service.py │ └── user_management_util.py │ ├── requirements.txt │ ├── tenant_authorizer.py │ ├── user_management.py │ └── utils │ ├── __init__.py │ ├── aggregator_util.py │ ├── auth_manager.py │ ├── idp_object_factory.py │ ├── logger.py │ ├── metrics_manager.py │ ├── requirements.txt │ └── utils.py ├── saas-control-plane ├── cdk │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── bin │ │ └── saas-control-plane.ts │ ├── cdk.json │ ├── jest.config.js │ ├── lib │ │ └── saas-control-plane-stack.ts │ ├── package-lock.json │ ├── package.json │ ├── test │ │ └── cdk.test.ts │ ├── tsconfig.json │ └── utils │ │ └── cdk-nag-utils.ts ├── package-lock.json └── scripts │ └── deploy.sh └── ws_deployment ├── _manage-workshop-stack.sh ├── _workshop-conf.sh ├── _workshop-shared-functions.sh └── manage-workshop-stack.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .venv 3 | .env 4 | .DS_Store -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cost Visibility and Usage Attribution in Multi-Tenant SaaS Environments 2 | 3 | Software-as-a-Service (SaaS) providers are constantly striving for cost and usage awareness across their users, tenants, features, and tiers. Understanding and optimizing the cost of running these various units inside your SaaS workload (users/tenants/features/tiers) is paramount for driving profitability and sustaining growth. 4 | 5 | This workshop aims to equip you with the knowledge and tools necessary to calculate and visualize your unit cost within a SaaS environment. While the approaches we will discuss apply to any type of unit, the focus here will be on the “cost per tenant”, which is the most common concern we see for SaaS providers operating their SaaS solutions on AWS. 6 | 7 | ## Starting the workshop 8 | 9 | Follow this [link](https://catalog.us-east-1.prod.workshops.aws/workshops/6743cde7-2471-46fc-863f-5830b98f3d36) for detailed instructions to run this workshop. 10 | 11 | ## License 12 | 13 | This library is licensed under the MIT-0 License. See the LICENSE file. -------------------------------------------------------------------------------- /Solution/quicksight/modify-quicksight-role.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 4 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 5 | export ROLE_NAME=CidQuickSightDataSourceRole 6 | echo "ROLE_NAME: ${ROLE_NAME}" 7 | export POLICY_NAME=TenantUsageAccess 8 | 9 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 10 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $SHARED_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 11 | echo "TENANT_USAGE_BUCKET: ${TENANT_USAGE_BUCKET}" 12 | 13 | inlinePolicy=$(cat <<-EOF 14 | { 15 | "Version": "2012-10-17", 16 | "Statement": [ 17 | { 18 | "Action": [ 19 | "glue:GetPartition", 20 | "glue:GetPartitions", 21 | "glue:GetDatabase", 22 | "glue:GetDatabases", 23 | "glue:GetTable", 24 | "glue:GetTables" 25 | ], 26 | "Resource": [ 27 | "arn:aws:glue:us-east-1:${ACCOUNT_ID}:database/tenant_daily_usage", 28 | "arn:aws:glue:us-east-1:${ACCOUNT_ID}:table/tenant_daily_usage/*" 29 | ], 30 | "Effect": "Allow", 31 | "Sid": "AllowGlueTenantUsage" 32 | }, 33 | { 34 | "Action": "s3:ListBucket", 35 | "Resource": [ 36 | "arn:aws:s3:::${TENANT_USAGE_BUCKET}" 37 | ], 38 | "Effect": "Allow", 39 | "Sid": "AllowListBucket" 40 | }, 41 | { 42 | "Action": [ 43 | "s3:GetObject", 44 | "s3:GetObjectVersion" 45 | ], 46 | "Resource": [ 47 | "arn:aws:s3:::${TENANT_USAGE_BUCKET}/*" 48 | ], 49 | "Effect": "Allow", 50 | "Sid": "AllowReadBucket" 51 | } 52 | ] 53 | } 54 | EOF 55 | ) 56 | 57 | #add a policy to the role using aws cli 58 | aws iam put-role-policy \ 59 | --role-name $ROLE_NAME \ 60 | --policy-name $POLICY_NAME \ 61 | --policy-document "${inlinePolicy}" 62 | 63 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/bin/product-media-service-app.ts: -------------------------------------------------------------------------------- 1 | import { Aspects, App } from 'aws-cdk-lib'; 2 | import { ApplicationPlaneStack } from '../lib/application-plane-stack'; 3 | import { TenantProvisionStack } from '../lib/tenant-provision-stack'; 4 | import { AwsSolutionsChecks } from 'cdk-nag' 5 | 6 | const app = new App(); 7 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 8 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 9 | 10 | // Environment variables. 11 | const accountId = process.env.ACCOUNT_ID; 12 | const region = process.env.REGION; 13 | const tenantId = process.env.TENANT_ID; 14 | const listenerRulePriorityBase = Number(process.env.PRIORITY_BASE); 15 | 16 | // Deploy base application stack. 17 | const env = {account: accountId, region: region} 18 | 19 | // Deploy tenant stack. 20 | if (tenantId && !isNaN(listenerRulePriorityBase)) { 21 | const stackName = `ProductMediaTenantStack-${tenantId}`; 22 | new TenantProvisionStack(app, stackName, {tenantId, listenerRulePriorityBase, env}); 23 | } else { 24 | new ApplicationPlaneStack(app, 'ProductMediaAppStack', {env}); 25 | } 26 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=891377098679:region=us-west-1": [ 3 | "us-west-1a", 4 | "us-west-1b" 5 | ], 6 | "availability-zones:account=230165232954:region=us-east-1": [ 7 | "us-east-1a", 8 | "us-east-1b", 9 | "us-east-1c", 10 | "us-east-1d", 11 | "us-east-1e", 12 | "us-east-1f" 13 | ], 14 | "availability-zones:account=686255951259:region=us-east-1": [ 15 | "us-east-1a", 16 | "us-east-1b", 17 | "us-east-1c", 18 | "us-east-1d", 19 | "us-east-1e", 20 | "us-east-1f" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "build": "tsc", 6 | "watch": "tsc -w", 7 | "test": "jest", 8 | "cdk": "cdk" 9 | }, 10 | "devDependencies": { 11 | "@types/jest": "^29.5.12", 12 | "@types/node": "20.11.19", 13 | "aws-cdk": "2.130.0", 14 | "jest": "^29.7.0", 15 | "ts-jest": "^29.1.2", 16 | "ts-node": "^10.9.2", 17 | "typescript": "~5.3.3" 18 | }, 19 | "dependencies": { 20 | "@aws-cdk/aws-lambda-python-alpha": "2.146.0-alpha.0", 21 | "aws-cdk-lib": "2.146.0", 22 | "cdk-nag": "2.28.195", 23 | "constructs": "^10.0.0", 24 | "source-map-support": "^0.5.21" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Server from '../lib/server-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/server-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Server.ServerStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "types": ["node", "jest"], 6 | "lib": [ 7 | "es2020", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "include": [ 29 | "bin/**/*.ts", 30 | "lib/**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "cdk.out" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-EC26', 13 | reason: 'EBS encryption unnecessary.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 18 | }, 19 | { 20 | id: 'AwsSolutions-VPC7', 21 | reason: 'VPC Flow logs unnecessary for current implementation, relying on access log from API Gateway.' 22 | }, 23 | { 24 | id: 'AwsSolutions-AS3', 25 | reason: 'Notifications not required for Auto Scaling Group.' 26 | }, 27 | { 28 | id: 'AwsSolutions-L1', 29 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 30 | }, 31 | { 32 | id: 'AwsSolutions-COG4', 33 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 34 | }, 35 | { 36 | id: 'AwsSolutions-SNS2', 37 | reason: 'Not utilizing SNS notifications.' 38 | }, 39 | { 40 | id: 'AwsSolutions-SNS3', 41 | reason: 'Not utilizing SNS notifications.' 42 | }, 43 | { 44 | id: 'AwsSolutions-S1', 45 | reason: 'Server access logs not required for S3 buckets.' 46 | }, 47 | { 48 | id: 'AwsSolutions-S10', 49 | reason: 'SSL not required for S3 buckets collecting access logs.' 50 | }, 51 | { 52 | id: 'AwsSolutions-EC23', 53 | reason: 'Security group rules are restricted to allow only necessary traffic (port 80 or 443) from ALB.' 54 | }, 55 | { 56 | id: 'AwsSolutions-ECS2', 57 | reason: 'Environment variables permitted in container definitions.' 58 | }, 59 | { 60 | id: 'AwsSolutions-ECS7', 61 | reason: 'Container logging not required.' 62 | }, 63 | 64 | ]); 65 | } 66 | } -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/deploy-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | echo "AWS_REGION: ${AWS_REGION}" 8 | export REGION=$AWS_REGION 9 | echo "REGION: ${REGION}" 10 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 11 | echo "Account ID: ${ACCOUNT_ID}" 12 | 13 | TENANT_ID=$1 14 | export TENANT_ID 15 | echo "Provision tenant: ${TENANT_ID}" 16 | MEDIA_SERVICES_STACK_NAME='ProductMediaAppStack' 17 | APPLICATION_PLANE_LISTENER_ARN=$(aws cloudformation describe-stacks --stack-name $MEDIA_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='ApplicationPlaneListenerArn'].OutputValue" --output text) 18 | HIGHEST_PRIORITY_BASE=$(aws elbv2 describe-rules --listener-arn $APPLICATION_PLANE_LISTENER_ARN --query 'Rules[*].Priority' --output text | tr '\t' '\n' | grep -v default | sort -rn | head -1) 19 | echo "HIGHEST_PRIORITY_BASE is from listener: $HIGHEST_PRIORITY_BASE" 20 | # Check if HIGHEST_PRIORITY_BASE is empty or not a number 21 | if [ -z "$HIGHEST_PRIORITY_BASE" ] || ! [[ "$HIGHEST_PRIORITY_BASE" =~ ^[0-9]+$ ]]; then 22 | HIGHEST_PRIORITY_BASE=10 23 | echo "PRIORITY_BASE was empty or not a number. Setting it to default value: $HIGHEST_PRIORITY_BASE" 24 | fi 25 | echo "HIGHEST_PRIORITY_BASE is now: $HIGHEST_PRIORITY_BASE" 26 | export PRIORITY_BASE=$(($HIGHEST_PRIORITY_BASE + 10)) 27 | echo "Deploying stack for $TENANT_ID with listener rule priority base $PRIORITY_BASE" 28 | 29 | cd ../cdk 30 | npm install 31 | # Executing the ApplicationPlaneStack CDK stack to create ECS Cluster, ALB, S3 Bucket, ECR, Parameter Store, APIGW Resource 32 | npx cdk deploy "ProductMediaTenantStack-${TENANT_ID}" --require-approval never --concurrency 10 --asset-parallelism true 33 | 34 | cd ../scripts 35 | echo $PRIORITY_BASE -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 4 | export REGION=$(aws configure get region) 5 | if [ -z "$REGION" ]; then 6 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 7 | export REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') && echo "export REGION=${REGION}" >> /home/ec2-user/.bashrc 8 | fi 9 | echo "REGION: ${REGION}" 10 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 11 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 12 | 13 | cd ../cdk 14 | echo ${PWD} 15 | 16 | npm install 17 | npm run build 18 | 19 | # Executing the ApplicationPlaneStack CDK stack to create ECS Cluster, ALB, S3 Bucket, ECR, Parameter Store, APIGW Resource 20 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 21 | 22 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $SHARED_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 23 | # Create a prefix in TENANT_USAGE_BUCKET 24 | aws s3api put-object --bucket $TENANT_USAGE_BUCKET --key s3_storage_lens_report/ 25 | 26 | 27 | # Building the code and preparing Product Media Docker Image 28 | cd ../src 29 | docker build --platform linux/amd64 -f resources/dockerfile -t product-media-service:latest . 30 | 31 | # Login to ECR 32 | aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com 33 | 34 | # Tag the image and push it to product-media-service ECR repo 35 | docker tag product-media-service:latest $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/product-media-service:latest 36 | docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/product-media-service:latest 37 | 38 | API_ID=$( 39 | aws cloudformation describe-stacks \ 40 | --stack-name $SHARED_SERVICES_STACK_NAME \ 41 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 42 | --output text 43 | ) 44 | echo "API_ID: $API_ID" 45 | 46 | # Re-Deploy API in Prod stage 47 | aws apigateway create-deployment \ 48 | --rest-api-id "$API_ID" \ 49 | --stage-name prod \ 50 | --description "Product Service services deployment." 51 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/last_listener_rule_priority_base.txt: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/resources/test-audio.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/product-media-service/scripts/resources/test-audio.mp4 -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/resources/test-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/product-media-service/scripts/resources/test-image.png -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/resources/test-text.txt: -------------------------------------------------------------------------------- 1 | This is workshop for Cost Visibility and Attribution in Multi-Tenant SaaS Environments -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 9 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 10 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 11 | 12 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 13 | 14 | echo "Checking if the results were saved in S3" 15 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/src/resources/dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image as the base image 2 | FROM python:3.9 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file first to leverage Docker cache 8 | COPY /resources/requirements.txt /app/resources/requirements.txt 9 | 10 | # Install any required Python packages 11 | RUN pip install --no-cache-dir --trusted-host pypi.python.org -r /app/resources/requirements.txt 12 | 13 | # Copy the rest of the application code to the container 14 | COPY . /app 15 | 16 | # Expose the port the app runs on (if applicable) 17 | EXPOSE 80 18 | 19 | # Set the entrypoint for the container 20 | CMD ["python", "/app/product_media.py"] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/src/resources/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.3 2 | boto3 3 | python-jose[cryptography] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-media-service/src/test.txt: -------------------------------------------------------------------------------- 1 | 2 | This is a test file needed to successfully run the unit test of Product Media Service -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk.out/synth.lock: -------------------------------------------------------------------------------- 1 | 51643 -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/bin/product-review-app.ts: -------------------------------------------------------------------------------- 1 | import { Aspects, App } from 'aws-cdk-lib'; 2 | import { ApplicationPlaneStack } from '../lib/application-plane-stack'; 3 | import { ECSServiceStack } from '../lib/ecs-service-stack'; 4 | import { TenantProvisioningStack } from '../lib/tenant-provisioning-stack'; 5 | import { UsageAggregator } from '../lib/UsageAggregatorConstruct'; 6 | import { AwsSolutionsChecks } from 'cdk-nag' 7 | 8 | const app = new App(); 9 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 10 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 11 | 12 | // Read environment variables 13 | const awsAccountId = process.env.AWS_ACCOUNT_ID; 14 | const awsRegion = process.env.AWS_REGION; 15 | 16 | const env = { account: awsAccountId, region: awsRegion }; 17 | 18 | // Create app plane 19 | new ApplicationPlaneStack(app, 'ProductReviewAppStack', {env}); 20 | 21 | const imageVersion = app.node.tryGetContext('imageVersion'); 22 | 23 | // Add ECS service, build the image before this 24 | new ECSServiceStack(app, 'ProductReviewECSServiceStack', { 25 | imageVersion: imageVersion, 26 | listenerRulePriorityBase: 100 27 | }); 28 | 29 | const tenantId = app.node.tryGetContext('tenantId'); 30 | 31 | new TenantProvisioningStack(app, `ProductReviewTenantProvisioningStack-${tenantId}`, {tenantId}) 32 | 33 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=686790399189:region=ap-northeast-1": [ 3 | "ap-northeast-1a", 4 | "ap-northeast-1c", 5 | "ap-northeast-1d" 6 | ], 7 | "vpc-provider:account=686790399189:filter.vpc-id=vpc-0b1fd9a7b0c4bf397:region=ap-northeast-1:returnAsymmetricSubnets=true": { 8 | "vpcId": "vpc-0b1fd9a7b0c4bf397", 9 | "vpcCidrBlock": "10.10.0.0/16", 10 | "ownerAccountId": "686790399189", 11 | "availabilityZones": [], 12 | "subnetGroups": [ 13 | { 14 | "name": "private", 15 | "type": "Private", 16 | "subnets": [ 17 | { 18 | "subnetId": "subnet-0a6643b55ae1fba78", 19 | "cidr": "10.10.0.0/18", 20 | "availabilityZone": "ap-northeast-1a", 21 | "routeTableId": "rtb-06deda6371ea28e80" 22 | }, 23 | { 24 | "subnetId": "subnet-0e5feefc1f183eabb", 25 | "cidr": "10.10.64.0/18", 26 | "availabilityZone": "ap-northeast-1c", 27 | "routeTableId": "rtb-031b225979955aa98" 28 | } 29 | ] 30 | }, 31 | { 32 | "name": "public", 33 | "type": "Public", 34 | "subnets": [ 35 | { 36 | "subnetId": "subnet-0241d9789134756f4", 37 | "cidr": "10.10.192.0/24", 38 | "availabilityZone": "ap-northeast-1a", 39 | "routeTableId": "rtb-05a38a8a2290e690c" 40 | }, 41 | { 42 | "subnetId": "subnet-0b4cfc04a4432db5b", 43 | "cidr": "10.10.193.0/24", 44 | "availabilityZone": "ap-northeast-1c", 45 | "routeTableId": "rtb-04b2f1e4d5593f251" 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "product-review-service", 3 | "version": "1.0.0", 4 | "description": "A service for managing product reviews", 5 | "scripts": { 6 | "build": "tsc", 7 | "watch": "tsc -w", 8 | "test": "jest", 9 | "cdk": "cdk" 10 | }, 11 | "dependencies": { 12 | "aws-cdk-lib": "2.154.1", 13 | "constructs": "^10.0.0", 14 | "cdk-nag": "2.28.195", 15 | "source-map-support": "^0.5.21", 16 | "@aws-cdk/aws-lambda-python-alpha": "2.154.1-alpha.0" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^29.5.12", 20 | "@types/node": "20.11.30", 21 | "aws-cdk": "2.154.1", 22 | "jest": "^29.7.0", 23 | "ts-jest": "^29.1.2", 24 | "ts-node": "^10.9.2", 25 | "typescript": "~5.2.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-VPC7', 13 | reason: 'VPC Flow logs unnecessary for current implementation, relying on access log from API Gateway.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix for AWS Managed policies.' 18 | }, 19 | { 20 | id: 'AwsSolutions-EC26', 21 | reason: 'EBS encryption unnecessary.' 22 | }, 23 | { 24 | id: 'AwsSolutions-AS3', 25 | reason: 'Notifications not required for Auto Scaling Group.' 26 | }, 27 | { 28 | id: 'AwsSolutions-L1', 29 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 30 | }, 31 | { 32 | id: 'AwsSolutions-SNS2', 33 | reason: 'Not utilizing SNS notifications.' 34 | }, 35 | { 36 | id: 'AwsSolutions-SNS3', 37 | reason: 'Not utilizing SNS notifications.' 38 | }, 39 | { 40 | id: 'AwsSolutions-S1', 41 | reason: 'Server access logs not required for S3 buckets.' 42 | }, 43 | { 44 | id: 'AwsSolutions-S10', 45 | reason: 'SSL not required for S3 buckets collecting access logs.' 46 | }, 47 | { 48 | id: 'AwsSolutions-COG4', 49 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 50 | }, 51 | { 52 | id: 'AwsSolutions-RDS10', 53 | reason: 'Deletion protection not required for RDS DB.' 54 | }, 55 | { 56 | id: 'AwsSolutions-ECS2', 57 | reason: 'Environment variables permitted in container definitions.' 58 | }, 59 | { 60 | id: 'AwsSolutions-EC23', 61 | reason: 'Configuration restricted to allow only necessary traffic (port 80 or 443) from ALB.' 62 | }, 63 | { 64 | id: 'AwsSolutions-SMG4', 65 | reason: 'Automatic rotation not necessary.' 66 | }, 67 | { 68 | id: 'AwsSolutions-RDS6', 69 | reason: 'Fine-grained access control methods in place to restrict tenant access.' 70 | }, 71 | ]); 72 | } 73 | } -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/scripts/deploy-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | echo "AWS_REGION: ${AWS_REGION}" 9 | export REGION=$AWS_REGION 10 | echo "REGION: ${REGION}" 11 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 12 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 13 | 14 | TENANT_ID=$1 15 | echo "Provision tenant: ${TENANT_ID}" 16 | 17 | cd ../cdk 18 | npm install 19 | echo ${PWD} 20 | 21 | npx cdk deploy "ProductReviewTenantProvisioningStack-$TENANT_ID" --app "npx ts-node bin/product-review-app.ts" \ 22 | --context tenantId=$TENANT_ID \ 23 | --require-approval never -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 4 | 5 | export AWS_REGION=$(aws configure get region) 6 | if [ -z "$AWS_REGION" ]; then 7 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 8 | export AWS_REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 9 | fi 10 | echo "REGION: ${AWS_REGION}" 11 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 12 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 13 | 14 | 15 | cd ../cdk 16 | echo ${PWD} 17 | 18 | npm install 19 | npm run build 20 | 21 | npx cdk deploy "ProductReviewAppStack" --app "npx ts-node bin/product-review-app.ts" --require-approval never 22 | 23 | IMAGE_VERSION=$(date +%s) 24 | echo "IMAGE_VERSION: ${IMAGE_VERSION}" 25 | 26 | cd ../src 27 | 28 | docker build --platform linux/amd64 -f resources/Dockerfile -t product-review-service:$IMAGE_VERSION . 29 | 30 | # Login to ECR 31 | aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com 32 | 33 | # Tag the image and push it to product-service ECR repo 34 | docker tag product-review-service:$IMAGE_VERSION $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-review-service:$IMAGE_VERSION 35 | 36 | docker push $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-review-service:$IMAGE_VERSION 37 | 38 | 39 | cd ../cdk 40 | 41 | npx cdk deploy "ProductReviewECSServiceStack" --app "npx ts-node bin/product-review-app.ts" \ 42 | --context imageVersion=$IMAGE_VERSION \ 43 | --require-approval never 44 | 45 | cd ../scripts 46 | 47 | API_ID=$( 48 | aws cloudformation describe-stacks \ 49 | --stack-name $SHARED_SERVICES_STACK_NAME \ 50 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 51 | --output text 52 | ) 53 | echo "API_ID: $API_ID" 54 | 55 | # Deploy API for good measure. 56 | aws apigateway create-deployment \ 57 | --rest-api-id "$API_ID" \ 58 | --stage-name prod \ 59 | --description "Product review services deployment." 60 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/scripts/out.json: -------------------------------------------------------------------------------- 1 | {"statusCode": 200, "body": "Data from pg_stat_statements uploaded to S3 at s3://sharedservicesstack-tenantusagebucket3870349d-gmnu1f34jnzb/fine_grained/year=2024/month=10/product-review-aurora_dbload_by_tenant-usage_by_tenant-10-24-2024.json"} -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | PRODUCT_REVIEW_APP_STACK=ProductReviewAppStack 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | FINE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='ECSUsageAggregatorLambda'].OutputValue" --output text) 9 | RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSPerformanceInsightsLambda'].OutputValue" --output text) 10 | RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSIopsUsageLambda'].OutputValue" --output text) 11 | RDS_AURORA_STORAGE_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSStorageUsageLambda'].OutputValue" --output text) 12 | 13 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 14 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 15 | echo "Fine Grained Aggregator: $FINE_GRAINED_AGGREGATOR" 16 | echo "RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR: $RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR" 17 | echo "RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR: $RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR" 18 | echo "RDS_AURORA_STORAGE_AGGREGATOR: $RDS_AURORA_STORAGE_AGGREGATOR" 19 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 20 | 21 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 22 | aws lambda invoke --function-name $FINE_GRAINED_AGGREGATOR out.json 23 | aws lambda invoke --function-name $RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR out.json 24 | aws lambda invoke --function-name $RDS_AURORA_STORAGE_AGGREGATOR out.json 25 | aws lambda invoke --function-name $RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR --cli-read-timeout 600 --cli-connect-timeout 600 out.json 26 | 27 | 28 | echo "Checking if the results were saved in S3" 29 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ 30 | aws s3 ls s3://$TENANT_USAGE_BUCKET/fine_grained/ -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/cdk.out/synth.lock: -------------------------------------------------------------------------------- 1 | 56409 -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | psycopg[binary,pool] 3 | jsonpickle 4 | simplejson -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/utils/__init__.py -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-rds/db-schema.sql: -------------------------------------------------------------------------------- 1 | -- Create a new schema 2 | CREATE SCHEMA app; 3 | 4 | -- Create a new table in the new schema 5 | CREATE TABLE app.product_reviews ( 6 | review_id text PRIMARY KEY, 7 | order_id INTEGER NOT NULL , 8 | product_id INTEGER NOT NULL , 9 | rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), 10 | review_description text NOT NULL, 11 | tenant_id TEXT NOT NULL, 12 | review_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | CONSTRAINT review_order_unique UNIQUE (review_id, order_id, product_id) 14 | ); 15 | 16 | -- enable RLS on pooled table 17 | ALTER TABLE app.product_reviews ENABLE ROW LEVEL SECURITY; 18 | CREATE POLICY tenant_user_isolation_policy ON app.product_reviews 19 | USING (tenant_id::TEXT = current_user); 20 | 21 | -- enable pg_stat_statements 22 | CREATE EXTENSION pg_stat_statements; 23 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-rds/rds-tenant-provision.sql: -------------------------------------------------------------------------------- 1 | -- Create a new user 2 | CREATE USER "" WITH PASSWORD ''; 3 | 4 | -- Grant permissions to tenant 5 | 6 | GRANT CONNECT ON DATABASE "" TO ""; 7 | GRANT USAGE ON SCHEMA app TO ""; 8 | GRANT ALL PRIVILEGES ON table app.product_reviews TO ""; 9 | 10 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-rds/rds.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | import psycopg 4 | import json 5 | 6 | secrets_manager = boto3.client('secretsmanager') 7 | 8 | def handler(event, context): 9 | try: 10 | creds_secret_name = os.getenv('DB_CRED_SECRET_NAME') 11 | db_name = os.getenv('DB_NAME') 12 | 13 | password, username, host, port = get_secret_value(creds_secret_name) 14 | 15 | connection = psycopg.connect(dbname='postgres', 16 | host=host, 17 | port=port, 18 | user=username, 19 | password=password, 20 | autocommit=True) 21 | query(connection, "CREATE DATABASE {0};".format(db_name)) 22 | connection.close() 23 | 24 | connection = psycopg.connect(dbname=db_name, 25 | host=host, 26 | port=port, 27 | user=username, 28 | password=password, 29 | autocommit=True) 30 | 31 | 32 | with open(os.path.join(os.path.dirname(__file__), 'db-schema.sql'), 'r') as f: 33 | sql_script = f.read() 34 | print(sql_script) 35 | query(connection, sql_script) 36 | connection.close() 37 | 38 | return { 39 | 'status': 'OK', 40 | 'results': "RDS Initialized" 41 | } 42 | except Exception as err: 43 | return { 44 | 'status': 'ERROR', 45 | 'err': str(err), 46 | 'message': str(err) 47 | } 48 | 49 | def query(connection, sql): 50 | connection.execute(sql) 51 | 52 | def get_secret_value(secret_id): 53 | response = secrets_manager.get_secret_value(SecretId=secret_id) 54 | 55 | #convert string to json 56 | secret_value = json.loads(response['SecretString']) 57 | 58 | password = secret_value["password"] 59 | username = secret_value["username"] 60 | host = secret_value["host"] 61 | port = secret_value["port"] 62 | 63 | return password, username, host, port 64 | 65 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/lambdas-rds/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg[binary,pool] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/resources/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image as the base image 2 | FROM public.ecr.aws/docker/library/python:3.12-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file first to leverage Docker cache 8 | COPY /resources/requirements.txt /app/resources/requirements.txt 9 | 10 | # Install any required Python packages 11 | RUN pip install --no-cache-dir --trusted-host pypi.python.org -r /app/resources/requirements.txt 12 | 13 | # Copy the rest of the application code to the container 14 | COPY review-service /app/review-service 15 | 16 | # Expose the port the app runs on (if applicable) 17 | EXPOSE 80 18 | 19 | # Set the entrypoint for the container 20 | CMD ["python", "/app/review-service/product_review_service.py"] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/resources/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.3 2 | boto3 3 | psycopg2-binary==2.9.9 4 | aws-embedded-metrics==3.2.0 5 | python-jose[cryptography] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/review-service/product_review_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import json 4 | from aws_embedded_metrics import metric_scope 5 | import logging 6 | 7 | logger = logging.getLogger('review_svc.logger') 8 | logger.setLevel(logging.DEBUG) 9 | 10 | @metric_scope 11 | async def create_emf_log(tenant_id, metric_name, metric_value, metric_unit, metrics): 12 | logger.info(f"inside emf logging...") 13 | try: 14 | 15 | metrics.put_dimensions({"Tenant": tenant_id}) 16 | metrics.put_metric(metric_name, metric_value, metric_unit) 17 | await metrics.flush() 18 | except Exception as e: 19 | logger.error(f"Error creating EMF log: {e}") 20 | 21 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/review-service/product_review_model.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | # Reviews class with product reviews attributes 5 | class Reviews: 6 | def __init__(self, review_id, product_id, order_id, rating, review_description, tenant_id): 7 | self.review_id = review_id 8 | self.product_id = product_id 9 | self.order_id = order_id 10 | self.rating = rating 11 | self.review_description = review_description 12 | self.tenant_id = tenant_id 13 | self.review_date = None 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-review-service/src/review-service/utils.py: -------------------------------------------------------------------------------- 1 | # Define the custom DatabaseError exception 2 | class DatabaseError(Exception): 3 | def __init__(self, error_message, error_code): 4 | self.error_message = error_message 5 | self.error_code = error_code 6 | super().__init__(f"Database error: {error_message}, Code: {error_code}") 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/bin/serverless-saas-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { ServerlessSaaSAppStack } from '../lib/serverless-saas-app-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 9 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 10 | 11 | new ServerlessSaaSAppStack(app, 'ServerlessSaaSAppStack', {}); -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/interfaces/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/serverless-saas-app.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "aws-cdk": "2.130.0", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.2", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "^2.146.0-alpha.0", 24 | "aws-cdk-lib": "^2.146.0", 25 | "cdk-nag": "2.28.195", 26 | "constructs": "^10.0.0", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "types": ["node", "jest"], 6 | "lib": [ 7 | "es2020", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | "cdk.out" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-IAM5', 13 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 14 | }, 15 | { 16 | id: 'AwsSolutions-L1', 17 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 18 | }, 19 | { 20 | id: 'AwsSolutions-APIG4', 21 | reason: 'Custom request authorizer is being used.' 22 | }, 23 | { 24 | id: 'AwsSolutions-COG4', 25 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 26 | }, 27 | ]); 28 | } 29 | } -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | pip install pylint 4 | 5 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 6 | 7 | cd ../src 8 | #python3 -m pylint -E -d E0401 $(find . -iname "*.py" -not -path "./.aws-sam/*" -not -path "./extensions/*") 9 | 10 | # Build extension. 11 | cd extensions/telemetry-api 12 | chmod +x telemetry_api_extension/extension.py 13 | pip3 install -r telemetry_api_extension/requirements.txt -t ./telemetry_api_extension/ 14 | 15 | chmod +x extensions/telemetry_api_extension 16 | # Check if we're on Windows 17 | if [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* ]]; then 18 | echo "Running on Windows, using 7-Zip" 19 | 7z a -tzip extension.zip extensions telemetry_api_extension 20 | else 21 | zip -r extension.zip extensions telemetry_api_extension 22 | fi 23 | 24 | 25 | cd ../../../cdk 26 | npm install 27 | npm run build 28 | 29 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 30 | 31 | # Deploy API services to stage. 32 | API_ID=$( 33 | aws cloudformation describe-stacks \ 34 | --stack-name $SHARED_SERVICES_STACK_NAME \ 35 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 36 | --output text 37 | ) 38 | echo "API_ID: $API_ID" 39 | 40 | # Deploy API for good measure. 41 | aws apigateway create-deployment \ 42 | --rest-api-id "$API_ID" \ 43 | --stage-name prod \ 44 | --description "Product services deployment." 45 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/scripts/out.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | FINE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SERVERLESS --query "Stacks[0].Outputs[?ExportName=='FineGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 9 | echo "Fine Grained Aggregator: $FINE_GRAINED_AGGREGATOR" 10 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 11 | 12 | aws lambda invoke --function-name $FINE_GRAINED_AGGREGATOR out.json 13 | 14 | echo "Checking if the results were saved in S3" 15 | aws s3 ls s3://$TENANT_USAGE_BUCKET/fine_grained/ -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/extensions/telemetry_api_extension: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | set -euo pipefail 6 | 7 | OWN_FILENAME="$(basename $0)" 8 | LAMBDA_EXTENSION_NAME="$OWN_FILENAME" # (external) extension name has to match the filename 9 | 10 | echo "${LAMBDA_EXTENSION_NAME} launching extension" 11 | python3 "/opt/${LAMBDA_EXTENSION_NAME}/extension.py" -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/extension.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import sys 5 | import time 6 | 7 | from pathlib import Path 8 | from queue import Queue 9 | from extensions_api_client import register_extension, next 10 | from telemetry_http_listener import start_http_listener 11 | from telemetery_api_client import subscibe_listener 12 | from telemetry_dispatcher import dispatch_telmetery 13 | 14 | def main(): 15 | print("Starting the Telemetry API Extension", flush=True) 16 | 17 | extension_name = Path(__file__).parent.name 18 | print("Extension Main: Registring the extension using extension name: {0}".format(extension_name), flush=True) 19 | extension_id = register_extension(extension_name) 20 | 21 | print("Extension Main: Starting the http listener which will receive data from Telemetry API", flush=True) 22 | queue = Queue() 23 | listener_url = start_http_listener(queue) 24 | 25 | print("Extension Main: Subscribing the listener to TelemetryAPI", flush=True) 26 | subscibe_listener(extension_id, listener_url) 27 | 28 | while True: 29 | print("Extension Main: Next", flush=True) 30 | 31 | event_data = next(extension_id) 32 | 33 | if event_data["eventType"] == "INVOKE": 34 | print ("Extension Main: Handle Invoke Event", flush=True) 35 | dispatch_telmetery(queue, False) 36 | elif event_data["eventType"] == "SHUTDOWN": 37 | # Wait for 1 sec to receive remaining events 38 | # nosem 39 | time.sleep(1) 40 | 41 | print ("Extension Main: Handle Shutdown Event", flush=True) 42 | dispatch_telmetery(queue, True) 43 | sys.exit(0) 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.34.131 2 | botocore==1.34.131 3 | urllib3==1.26.15 4 | requests -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetery_api_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import os 5 | from re import T 6 | import requests 7 | import json 8 | 9 | TELEMETRY_API_URL = "http://{0}/2022-07-01/telemetry".format(os.getenv("AWS_LAMBDA_RUNTIME_API")) 10 | LAMBDA_EXTENSION_IDENTIFIER_HEADER_KEY = "Lambda-Extension-Identifier" 11 | 12 | TIMEOUT_MS = 1000; # Maximum time (in milliseconds) that a batch is buffered. 13 | MAX_BYTES = 256*1024; # Maximum size in bytes that the logs are buffered in memory. 14 | MAX_ITEMS = 10000; # Maximum number of events that are buffered in memory. 15 | 16 | def subscibe_listener(extension_id, listener_url): 17 | print ("[telemetry_api_client.subscibe_listener] Subscribing Extension to receive telemetry data. ExtenionsId: {0}, listener url: {1}, telemetry api url: {2}".format(extension_id, listener_url, TELEMETRY_API_URL)) 18 | 19 | try: 20 | subscription_request_body = { 21 | "schemaVersion": "2022-07-01", 22 | "destination": { 23 | "protocol": "HTTP", 24 | "URI": listener_url, 25 | }, 26 | "types": ["platform", "function", "extension"], 27 | "buffering": { 28 | "timeoutMs": TIMEOUT_MS, 29 | "maxBytes": MAX_BYTES, 30 | "maxItems": MAX_ITEMS 31 | } 32 | }; 33 | 34 | subscription_request_headers = { 35 | "Content-Type": "application/json", 36 | LAMBDA_EXTENSION_IDENTIFIER_HEADER_KEY: extension_id, 37 | } 38 | 39 | response = requests.put( 40 | TELEMETRY_API_URL, 41 | data = json.dumps(subscription_request_body), 42 | headers= subscription_request_headers 43 | ) 44 | 45 | if response.status_code == 200: 46 | print("[telemetry_api_client.subscibe_listener] Extension successfully subscribed to telemetry api", response.text, flush=True) 47 | elif response.status_code == 202: 48 | print("[telemetry_api_client.subscibe_listener] Telemetry API not supported. Are you running the extension locally?", flush=True) 49 | else: 50 | print("[telemetry_api_client.subscibe_listener] Subsciption to telmetry API failed. ", "status code: ", response.status_code, "response text: ", response.text, flush=True) 51 | return extension_id 52 | 53 | except Exception as e: 54 | print("Error registering extension.", e, flush=True) 55 | raise Exception("Error setting AWS_LAMBDA_RUNTIME_API", e) 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetry_dispatcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from telemetry_service import ( 4 | log_telemetry_stream, 5 | ) 6 | 7 | DISPATCH_MIN_BATCH_SIZE = int(os.getenv("DISPATCH_MIN_BATCH_SIZE")); 8 | 9 | 10 | def dispatch_telmetery(queue, force): 11 | while ((not queue.empty()) and (force or queue.qsize() >= DISPATCH_MIN_BATCH_SIZE)): 12 | print("[telementry_dispatcher] Dispatch telemetry data") 13 | batch = queue.get_nowait() 14 | log_telemetry_stream(batch) 15 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/test.json: -------------------------------------------------------------------------------- 1 | [{'time': '2022-09-13T15:42:46.607Z', 'type': 'platform.initStart', 'record': {'initializationType': 'on-demand'}}, {'time': '2022-09-13T15:42:46.664Z', 'type': 'extension', 'record': 'telemetry_api_extension launching extension\n'}, {'time': '2022-09-13T15:42:46.948Z', 'type': 'extension', 'record': 'Starting the Telemetry API Extension\n'}, {'time': '2022-09-13T15:42:46.948Z', 'type': 'extension', 'record': 'Extension Main: Registring the extension using extension name: telemetry_api_extension\n'}, {'time': '2022-09-13T15:42:46.952Z', 'type': 'extension', 'record': '[extension_api_client.register_extension] Registering Extension using http://127.0.0.1:9001/2020-01-01/extension\n'}, {'time': '2022-09-13T15:42:46.953Z', 'type': 'extension', 'record': '[extension_api_client.register_extension] Registration success with extensionId d99e7a61-47be-4456-84d1-d85f29a7a2e3\n'}, {'time': '2022-09-13T15:42:46.953Z', 'type': 'extension', 'record': 'Extension Main: Starting the http listener which will receive data from Telemetry API\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': '[telemetery_http_listener.start_http_listener] Starting http listener on sandbox.localdomain:4243\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': '[telemetery_http_listener.start_http_listener] Started http listener\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': 'Extension Main: Subscribing the listener to TelemetryAPI\n'}] -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/test_req.txt: -------------------------------------------------------------------------------- 1 | altgraph @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/altgraph-0.17.2-py2.py3-none-any.whl 2 | astroid==3.2.2 3 | boto3==1.34.131 4 | botocore==1.34.131 5 | dill==0.3.8 6 | future @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/future-0.18.2-py3-none-any.whl 7 | git-remote-codecommit==1.16 8 | isort==5.13.2 9 | jmespath==1.0.1 10 | macholib @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/macholib-1.15.2-py2.py3-none-any.whl 11 | mccabe==0.7.0 12 | platformdirs==4.2.2 13 | pylint==3.2.3 14 | python-dateutil==2.8.2 15 | s3transfer==0.10.1 16 | six @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/six-1.15.0-py2.py3-none-any.whl 17 | tomli==2.0.1 18 | tomlkit==0.12.5 19 | typing_extensions==4.12.2 20 | urllib3==1.26.15 21 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/models/order_models.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | class Order: 5 | key='' 6 | def __init__(self, shardId, orderId, orderName, orderProducts): 7 | self.shardId = shardId 8 | self.orderId = orderId 9 | self.key = shardId + ':' + orderId 10 | self.orderName = orderName 11 | self.orderProducts = orderProducts 12 | 13 | class OrderProduct: 14 | 15 | def __init__(self, productId, price, quantity): 16 | self.productId = productId 17 | self.price = price 18 | self.quantity = quantity 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/models/product_models.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | class Product: 5 | key ='' 6 | def __init__(self, shardId, productId, sku, name, price, category): 7 | self.shardId = shardId 8 | self.productId = productId 9 | self.key = shardId + ':' + productId 10 | self.sku = sku 11 | self.name = name 12 | self.price = price 13 | self.category = category 14 | 15 | class Category: 16 | def __init__(self, id, name): 17 | self.id = id 18 | self.name = name 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/requirements.txt: -------------------------------------------------------------------------------- 1 | aws_lambda_powertools[Tracer,Logger,Metrics] 2 | jsonpickle 3 | aws_requests_auth 4 | requests 5 | simplejson 6 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/product-service/src/utils/__init__.py -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/utils/aggregator_util.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | from decimal import * 4 | import json 5 | 6 | 7 | def query_cloudwatch_logs(logs, log_group_name, query_string, start_time, end_time) -> dict: 8 | query = logs.start_query(logGroupName=log_group_name, 9 | startTime=start_time, 10 | endTime=end_time, 11 | queryString=query_string) 12 | 13 | query_results = logs.get_query_results(queryId=query["queryId"]) 14 | 15 | while query_results['status'] == 'Running' or query_results['status'] == 'Scheduled': 16 | # nosem 17 | time.sleep(5) 18 | query_results = logs.get_query_results(queryId=query["queryId"]) 19 | 20 | return query_results 21 | 22 | 23 | def get_start_date_time(): 24 | time_zone = datetime.now().astimezone().tzinfo 25 | start_date_time = int(datetime.now(tz=time_zone).date().strftime('%s')) # current day epoch 26 | return start_date_time 27 | 28 | 29 | def get_end_date_time(): 30 | time_zone = datetime.now().astimezone().tzinfo 31 | end_date_time = int((datetime.now(tz=time_zone) + timedelta(days=1)).date().strftime('%s')) # next day epoch 32 | return end_date_time 33 | 34 | 35 | def get_s3_key(prefix, service): 36 | # Get the current date and time. 37 | now = datetime.now() 38 | 39 | # Format strings for year, month, and current date. 40 | year = now.strftime('%Y') # Current year like '2024'. 41 | month = now.strftime('%m') # Current month like '07'. 42 | current_date = now.strftime('%m-%d-%Y') # Current date like '07-30-2024'. 43 | 44 | # Format the key with the current year, month, and date 45 | key = prefix + '/year={}/month={}/{}-usage_by_tenant-{}.json'.format(year, month, service, current_date) 46 | return key 47 | 48 | 49 | def get_line_delimited_json(data): 50 | # Initialize an empty string to hold all JSON strings. 51 | line_delimited_json = "" 52 | 53 | # Loop through each dictionary in the list, convert it to a JSON string, and append it to the string with a newline. 54 | for item in data: 55 | line_delimited_json += json.dumps(item) + "\n" 56 | 57 | return line_delimited_json 58 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/utils/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from aws_lambda_powertools import Logger 5 | logger = Logger() 6 | 7 | """Log info messages 8 | """ 9 | def info(log_message): 10 | # logger.structure_logs(append=True, tenant_id=tenant_id) 11 | logger.info(log_message) 12 | 13 | """Log error messages 14 | """ 15 | def error(log_message): 16 | # logger.structure_logs(append=True, tenant_id=tenant_id) 17 | logger.error(log_message) 18 | 19 | """Log with tenant context. Extracts tenant context from the lambda events 20 | """ 21 | def log_with_tenant_context(event, log_message): 22 | print(event) 23 | logger.structure_logs(append=True, tenant_id=event['requestContext']['authorizer']['tenantId']) 24 | logger.info(log_message) 25 | 26 | 27 | """Log with tenant context. Extracts tenant context from the lambda events 28 | """ 29 | def log_with_tenant_and_function_context(event, context, log_dict, log_message): 30 | tenant_log = { 31 | "type": "function.tenantUsage", 32 | "resource": event['resource'], 33 | "httpMethod": event['httpMethod'], 34 | "tenant_id": event['requestContext']['authorizer']['tenantId'], 35 | "tenant_tier": event['requestContext']['authorizer']['tenantTier'], 36 | "functionName": context.function_name, 37 | "functionVersion": context.function_version, 38 | "awsRequestId": context.aws_request_id, 39 | } 40 | 41 | log = tenant_log | log_dict # Merge additional log properties. 42 | logger.append_keys(**log) 43 | logger.structure_logs(append=True) 44 | logger.info(log_message) 45 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/utils/metrics_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | from aws_lambda_powertools import Metrics 6 | 7 | metrics = Metrics() 8 | 9 | 10 | def record_metric(event, metric_name, metric_unit, metric_value): 11 | """ Record the metric in Cloudwatch using EMF format 12 | 13 | Args: 14 | event ([type]): [description] 15 | metric_name ([type]): [description] 16 | metric_unit ([type]): [description] 17 | metric_value ([type]): [description] 18 | """ 19 | metrics.add_dimension(name="tenant_id", value=event['requestContext']['authorizer']['tenantId']) 20 | metrics.add_metric(name=metric_name, unit=metric_unit, value=metric_value) 21 | metrics_object = metrics.serialize_metric_set() 22 | metrics.clear_metrics() 23 | print(json.dumps(metrics_object)) 24 | 25 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/product-service/src/utils/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | aws-lambda-powertools[Tracer,Logger,Metrics] 3 | jsonpickle 4 | aws_requests_auth 5 | simplejson 6 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/bin/shared-services.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { SharedServicesStack } from '../lib/shared-services-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | 9 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 10 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 11 | 12 | new SharedServicesStack(app, 'SharedServicesStack', { 13 | env: { 14 | account: process.env.CDK_DEFAULT_ACCOUNT, 15 | region: process.env.CDK_DEFAULT_REGION 16 | } 17 | }); -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=686790399189:region=ap-south-1": [ 3 | "ap-south-1a", 4 | "ap-south-1b", 5 | "ap-south-1c" 6 | ], 7 | "availability-zones:account=686790399189:region=ap-northeast-1": [ 8 | "ap-northeast-1a", 9 | "ap-northeast-1c", 10 | "ap-northeast-1d" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/interfaces/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/lib/identity-provider.ts: -------------------------------------------------------------------------------- 1 | import { aws_cognito, StackProps, RemovalPolicy } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { IdentityDetails } from '../interfaces/identity-details'; 4 | 5 | export class IdentityProvider extends Construct { 6 | public readonly identityDetails: IdentityDetails; 7 | 8 | constructor(scope: Construct, id: string, props?: StackProps) { 9 | super(scope, id); 10 | 11 | const tenantUserPool = new aws_cognito.UserPool(this, 'tenantUserPool', { 12 | autoVerify: {email: true}, 13 | accountRecovery: aws_cognito.AccountRecovery.EMAIL_ONLY, 14 | removalPolicy: RemovalPolicy.DESTROY, 15 | advancedSecurityMode: aws_cognito.AdvancedSecurityMode.ENFORCED, 16 | passwordPolicy: { 17 | minLength: 8, 18 | requireLowercase: true, 19 | requireUppercase: true, 20 | requireSymbols: true, 21 | requireDigits: true 22 | }, 23 | standardAttributes: { 24 | email: { 25 | required: true, 26 | mutable: true, 27 | }, 28 | }, 29 | customAttributes: { 30 | tenantId: new aws_cognito.StringAttribute({ 31 | mutable: true, 32 | }), 33 | userRole: new aws_cognito.StringAttribute({ 34 | mutable: true, 35 | }), 36 | tenantTier: new aws_cognito.StringAttribute({ 37 | mutable: true, 38 | }), 39 | features: new aws_cognito.StringAttribute({ 40 | mutable: true, 41 | }) 42 | }, 43 | }); 44 | 45 | const writeAttributes = new aws_cognito.ClientAttributes() 46 | .withStandardAttributes({email: true}) 47 | .withCustomAttributes('tenantId', 'userRole', 'tenantTier','features'); 48 | 49 | const tenantUserPoolClient = new aws_cognito.UserPoolClient(this, 'tenantUserPoolClient', { 50 | userPool: tenantUserPool, 51 | generateSecret: false, 52 | authFlows: { 53 | userPassword: true, 54 | adminUserPassword: true, 55 | userSrp: true, 56 | custom: false, 57 | }, 58 | writeAttributes: writeAttributes, 59 | oAuth: { 60 | scopes: [ 61 | aws_cognito.OAuthScope.EMAIL, 62 | aws_cognito.OAuthScope.OPENID, 63 | aws_cognito.OAuthScope.PROFILE, 64 | ], 65 | flows: { 66 | authorizationCodeGrant: true, 67 | implicitCodeGrant: true, 68 | }, 69 | }, 70 | }); 71 | 72 | this.identityDetails = { 73 | name: 'Cognito', 74 | details: { 75 | userPoolId: tenantUserPool.userPoolId, 76 | appClientId: tenantUserPoolClient.userPoolClientId, 77 | }, 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/lib/saas-tenant-provision.ts: -------------------------------------------------------------------------------- 1 | import {Stack, StackProps, Fn, Tags} from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { 4 | CoreApplicationPlane, 5 | DetailType, 6 | EventManager 7 | } from "@cdklabs/sbt-aws"; 8 | import { EventBus } from 'aws-cdk-lib/aws-events'; 9 | 10 | import { PolicyDocument } from "aws-cdk-lib/aws-iam"; 11 | import * as fs from "fs"; 12 | 13 | 14 | export class SaaSTenantProvision extends Construct { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id); 17 | 18 | const eventBusArn = Fn.importValue('ControlPlaneEventBusArn') 19 | 20 | const provisioningJobRunnerProps = { 21 | name: "provisioning", 22 | permissions: PolicyDocument.fromJson( 23 | JSON.parse(` 24 | { 25 | "Version":"2012-10-17", 26 | "Statement":[ 27 | { 28 | "Action":[ 29 | "*" 30 | ], 31 | "Resource":"*", 32 | "Effect":"Allow" 33 | } 34 | ] 35 | } 36 | `) 37 | ), 38 | script: fs.readFileSync("../../provision-tenant.sh", "utf8"), 39 | environmentJSONVariablesFromIncomingEvent: [ 40 | "tenantId", 41 | "tenantName", 42 | "email", 43 | "tenantTier", 44 | "tenantStatus", 45 | "features" 46 | ], 47 | environmentVariablesToOutgoingEvent: ["tenantStatus"], 48 | scriptEnvironmentVariables: {}, 49 | outgoingEvent: DetailType.PROVISION_SUCCESS, 50 | incomingEvent: DetailType.ONBOARDING_REQUEST, 51 | }; 52 | 53 | const eventBus = EventBus.fromEventBusArn(this, 'EventBus', eventBusArn); 54 | const eventManagerNew = new EventManager(this, 'EventManager', { 55 | eventBus: eventBus, 56 | }); 57 | 58 | new CoreApplicationPlane(this, "CoreApplicationPlane", { 59 | eventManager: eventManagerNew, 60 | jobRunnerPropsList: [provisioningJobRunnerProps], 61 | }); 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/lib/usage-aggregator.ts: -------------------------------------------------------------------------------- 1 | import { Stack, CfnOutput, Fn } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { LambdaFunction } from './lambda-function'; 4 | import * as path from "path"; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | 7 | export interface UsageAggregatorProps { 8 | serverlessSaaSAPIAccessLogArn: string; 9 | serverlessSaaSAPIAccessLogName: string; 10 | tenantUsageBucketName: string; 11 | tenantUsageBucketArn: string; 12 | } 13 | 14 | export class SharedServicesUsageAggregatorStack extends Construct { 15 | constructor(scope: Construct, id: string, props: UsageAggregatorProps) { 16 | super(scope, id); 17 | 18 | const serverlessSaaSAPIAccessLogArn = props.serverlessSaaSAPIAccessLogArn 19 | const serverlessSaaSAPIAccessLogName = props.serverlessSaaSAPIAccessLogName 20 | const tenantUsageBucketArn = props.tenantUsageBucketArn; 21 | const tenantUsageBucketName = props.tenantUsageBucketName; 22 | 23 | const coarseGrainedAggregatorLambda = new LambdaFunction(this, 'CoarseGrainedAggregatorLambda', { 24 | entry: path.join(__dirname, '../../src'), 25 | handler: 'lambda_handler', 26 | index: 'coarse_grained_aggregator.py', 27 | powertoolsServiceName: 'COARSE_GRAINED_AGGREGATOR', 28 | powertoolsNamespace: 'TenantUsageAggregator', 29 | logLevel: 'DEBUG', 30 | }); 31 | 32 | coarseGrainedAggregatorLambda.lambdaFunction.addToRolePolicy( 33 | new iam.PolicyStatement({ 34 | effect: iam.Effect.ALLOW, 35 | actions: ['logs:StartQuery', 'logs:GetQueryResults'], 36 | resources: [serverlessSaaSAPIAccessLogArn], 37 | }) 38 | ); 39 | 40 | coarseGrainedAggregatorLambda.lambdaFunction.addToRolePolicy( 41 | new iam.PolicyStatement({ 42 | effect: iam.Effect.ALLOW, 43 | actions: ['s3:PutObject'], 44 | resources: [`${tenantUsageBucketArn}/*`], 45 | }) 46 | ); 47 | coarseGrainedAggregatorLambda.lambdaFunction.addEnvironment('SERVERLESS_SAAS_API_GATEWAY_ACCESS_LOGS', serverlessSaaSAPIAccessLogName); 48 | coarseGrainedAggregatorLambda.lambdaFunction.addEnvironment('TENANT_USAGE_BUCKET', tenantUsageBucketName); 49 | 50 | new CfnOutput(this, 'CoarseGrainedUsageAggregatorLambda', { 51 | value: coarseGrainedAggregatorLambda.lambdaFunction.functionName, 52 | exportName: 'CoarseGrainedUsageAggregatorLambda', 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/tenant-usr-mgmt.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "aws-cdk": "2.130.0", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.2", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "^2.146.0-alpha.0", 24 | "@cdklabs/sbt-aws": "0.1.5", 25 | "aws-cdk-lib": "2.146.0", 26 | "cdk-nag": "2.28.195", 27 | "constructs": "^10.0.0", 28 | "source-map-support": "^0.5.21" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-S1', 9 | reason: 'Disable S3 Bucket access logs. Not required for use case or compliance needs.' 10 | }, 11 | { 12 | id: 'AwsSolutions-IAM4', 13 | reason: 'AWS Managed policies are permitted.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 18 | }, 19 | { 20 | id: 'AwsSolutions-L1', 21 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 22 | }, 23 | { 24 | id: 'AwsSolutions-APIG2', 25 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 26 | }, 27 | { 28 | id: 'AwsSolutions-APIG4', 29 | reason: 'Custom request authorizer is being used.' 30 | }, 31 | { 32 | id: 'AwsSolutions-COG4', 33 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 34 | }, 35 | ]); 36 | } 37 | } -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | pip install pylint 4 | 5 | cd ../src 6 | 7 | cd ../cdk 8 | npm install 9 | npm run build 10 | 11 | cdk bootstrap 12 | echo "CDK Bootstrap complete" 13 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 14 | echo "CDK Deploy complete" -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/scripts/out.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/scripts/package-app-plane.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PWD 3 | APP_PLANE_ARCHIVE_FILENAME="../../../saas-app-plane.zip" 4 | ZIP_FILE_NAME="package/saas-app-plane.zip" 5 | 6 | if [ -f "$APP_PLANE_ARCHIVE_FILENAME" ]; then 7 | rm "$APP_PLANE_ARCHIVE_FILENAME" 8 | fi 9 | 10 | cd ../../ 11 | PWD 12 | zip -r ../saas-app-plane.zip . -x ".git/*" -x "**/node_modules/*" -x "**/cdk.out/*" 13 | 14 | echo $ZIP_FILE_NAME 15 | 16 | if [ $# -eq 1 ]; then 17 | if [ $1 == "upload" ]; then 18 | cd ./shared-services/scripts/ 19 | PWD 20 | BUCKET_NAME=$(aws cloudformation describe-stacks --stack-name SharedServicesStack --query "Stacks[0].Outputs[?ExportName=='AthenaOutputBucketName'].OutputValue" | jq -r '.[0]') 21 | aws s3 cp $APP_PLANE_ARCHIVE_FILENAME s3://$BUCKET_NAME/$ZIP_FILE_NAME 22 | fi 23 | fi -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SHAREDINFRA=SharedServicesStack 4 | echo "Testing Usage Aggregator Service" 5 | 6 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 7 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 8 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 9 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 10 | 11 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 12 | 13 | echo "Checking if the results were saved in S3" 14 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/shared-services/src/__init__.py -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/abstract_classes/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/abstract_classes/idp_authorizer_abstract_class.py: -------------------------------------------------------------------------------- 1 | import abc 2 | class IdpAuthorizerAbstractClass (abc.ABC): 3 | 4 | @abc.abstractmethod 5 | def validateJWT(self,event): 6 | pass -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/abstract_classes/idp_user_management_abstract_class.py: -------------------------------------------------------------------------------- 1 | import abc 2 | class IdpUserManagementAbstractClass (abc.ABC): 3 | 4 | @abc.abstractmethod 5 | def create_user(self, event): 6 | pass 7 | 8 | @abc.abstractmethod 9 | def get_users(self, event): 10 | pass 11 | 12 | @abc.abstractmethod 13 | def get_user(self, event): 14 | pass 15 | 16 | @abc.abstractmethod 17 | def update_user(self, event): 18 | pass 19 | 20 | @abc.abstractmethod 21 | def disable_user(self, event): 22 | pass 23 | 24 | @abc.abstractmethod 25 | def enable_user(self, event): 26 | pass 27 | 28 | @abc.abstractmethod 29 | def delete_user(self, event): 30 | pass -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/cognito/user_management_util.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | 3 | cognito = boto3.client('cognito-idp') 4 | 5 | 6 | def create_user_group(user_pool_id, group_name): 7 | response = cognito.create_group( 8 | GroupName=group_name, 9 | UserPoolId=user_pool_id, 10 | Precedence=0 11 | ) 12 | return response 13 | 14 | 15 | def create_user(user_pool_id, user_details): 16 | response = cognito.admin_create_user( 17 | Username=user_details['userName'], 18 | UserPoolId=user_pool_id, 19 | ForceAliasCreation=True, 20 | UserAttributes= 21 | [ 22 | { 23 | 'Name': 'email', 24 | 'Value': user_details['userEmail'] 25 | }, 26 | { 27 | 'Name': 'email_verified', 28 | 'Value': 'true' 29 | }, 30 | { 31 | 'Name': 'custom:userRole', 32 | 'Value': user_details['userRole'] 33 | }, 34 | { 35 | 'Name': 'custom:tenantId', 36 | 'Value': user_details['tenantId'] 37 | }, 38 | 39 | ] 40 | ) 41 | return response 42 | 43 | def add_user_to_group(user_pool_id, user_name, group_name): 44 | response = cognito.admin_add_user_to_group( 45 | UserPoolId=user_pool_id, 46 | Username=user_name, 47 | GroupName=group_name 48 | ) 49 | return response 50 | 51 | def user_group_exists(user_pool_id, group_name): 52 | try: 53 | response=cognito.get_group( 54 | UserPoolId=user_pool_id, 55 | GroupName=group_name) 56 | return True 57 | except Exception as e: 58 | return False 59 | 60 | def validate_user_tenancy(user_pool_id, user_name, group_name): 61 | isValid = False 62 | list_of_groups = cognito.admin_list_groups_for_user( 63 | UserPoolId=user_pool_id, 64 | Username=user_name 65 | ) 66 | for group in list_of_groups['Groups']: 67 | if group['GroupName'] == group_name: 68 | isValid = True 69 | break 70 | return isValid 71 | 72 | 73 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools[all] 2 | python-jose[cryptography] 3 | simplejson 4 | jsonpickle 5 | aws_requests_auth -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/Solution/saas-app-plane/shared-services/src/utils/__init__.py -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/aggregator_util.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | from decimal import * 4 | import json 5 | 6 | 7 | def query_cloudwatch_logs(logs, log_group_name, query_string, start_time, end_time) -> dict: 8 | query = logs.start_query(logGroupName=log_group_name, 9 | startTime=start_time, 10 | endTime=end_time, 11 | queryString=query_string) 12 | 13 | query_results = logs.get_query_results(queryId=query["queryId"]) 14 | 15 | while query_results['status'] == 'Running' or query_results['status'] == 'Scheduled': 16 | # nosem 17 | time.sleep(5) 18 | query_results = logs.get_query_results(queryId=query["queryId"]) 19 | 20 | return query_results 21 | 22 | 23 | def get_start_date_time(): 24 | time_zone = datetime.now().astimezone().tzinfo 25 | start_date_time = int(datetime.now(tz=time_zone).date().strftime('%s')) # current day epoch 26 | return start_date_time 27 | 28 | 29 | def get_end_date_time(): 30 | time_zone = datetime.now().astimezone().tzinfo 31 | end_date_time = int((datetime.now(tz=time_zone) + timedelta(days=1)).date().strftime('%s')) # next day epoch 32 | return end_date_time 33 | 34 | 35 | def get_s3_key(prefix, service): 36 | # Get the current date and time. 37 | now = datetime.now() 38 | 39 | # Format strings for year, month, and current date. 40 | year = now.strftime('%Y') # Current year like '2024'. 41 | month = now.strftime('%m') # Current month like '07'. 42 | current_date = now.strftime('%m-%d-%Y') # Current date like '07-30-2024'. 43 | 44 | # Format the key with the current year, month, and date 45 | key = prefix + '/year={}/month={}/{}-usage_by_tenant-{}.json'.format(year, month, service, current_date) 46 | return key 47 | 48 | 49 | def get_line_delimited_json(data): 50 | # Initialize an empty string to hold all JSON strings. 51 | line_delimited_json = "" 52 | 53 | # Loop through each dictionary in the list, convert it to a JSON string, and append it to the string with a newline. 54 | for item in data: 55 | line_delimited_json += json.dumps(item) + "\n" 56 | 57 | return line_delimited_json 58 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/idp_object_factory.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | def get_idp_user_mgmt_object(idp_name): 4 | 5 | idp_impl_class = '' 6 | if (idp_name.upper() == 'COGNITO'): 7 | idp_impl_class = getattr(importlib.import_module("cognito.cognito_user_management_service"), "CognitoUserManagementService") 8 | 9 | return idp_impl_class() 10 | 11 | def get_idp_authorizer_object(idp_name): 12 | 13 | idp_impl_class = '' 14 | if (idp_name.upper() == 'COGNITO'): 15 | idp_impl_class = getattr(importlib.import_module("cognito.cognito_authorizer"), "CognitoAuthorizer") 16 | 17 | return idp_impl_class() 18 | 19 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from aws_lambda_powertools import Logger 5 | logger = Logger() 6 | 7 | """Log info messages 8 | """ 9 | def info(log_message): 10 | #logger.structure_logs(append=True, tenant_id=tenant_id) 11 | logger.info (log_message) 12 | 13 | """Log error messages 14 | """ 15 | def error(log_message): 16 | #logger.structure_logs(append=True, tenant_id=tenant_id) 17 | logger.error (log_message) 18 | 19 | """Log with tenant context. Extracts tenant context from the lambda events 20 | """ 21 | def log_with_tenant_context(event, log_message): 22 | print(event) 23 | logger.structure_logs(append=True, tenant_id= event['requestContext']['authorizer']['tenantId']) 24 | logger.info (log_message) -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/metrics_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | from aws_lambda_powertools import Metrics 6 | 7 | metrics = Metrics() 8 | 9 | 10 | def record_metric(event, metric_name, metric_unit, metric_value): 11 | """ Record the metric in Cloudwatch using EMF format 12 | 13 | Args: 14 | event ([type]): [description] 15 | metric_name ([type]): [description] 16 | metric_unit ([type]): [description] 17 | metric_value ([type]): [description] 18 | """ 19 | metrics.add_dimension(name="tenant_id", value=event['requestContext']['authorizer']['tenantId']) 20 | metrics.add_metric(name=metric_name, unit=metric_unit, value=metric_value) 21 | metrics_object = metrics.serialize_metric_set() 22 | metrics.clear_metrics() 23 | print(json.dumps(metrics_object)) 24 | 25 | -------------------------------------------------------------------------------- /Solution/saas-app-plane/shared-services/src/utils/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools[all] 2 | python-jose[cryptography] 3 | simplejson 4 | jsonpickle 5 | aws_requests_auth -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/README.md: -------------------------------------------------------------------------------- 1 | To deploy the control plane 2 | 3 | ``` 4 | cd scripts 5 | ./deploy.sh 6 | ``` 7 | 8 | Deploy the app plane before running the below tests 9 | 10 | To test the control plane tenant management api 11 | 12 | ``` 13 | ./test.sh 14 | ``` 15 | 16 | 17 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/bin/saas-control-plane.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { SaaSControlPlaneStack } from '../lib/saas-control-plane-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 9 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 10 | 11 | // required input parameters 12 | if (!process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL) { 13 | throw new Error("Please provide system admin email"); 14 | } 15 | 16 | if (!process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME) { 17 | process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME = "SystemAdmin"; 18 | } 19 | 20 | const controlPlaneStack = new SaaSControlPlaneStack(app, 'SaaSControlPlaneStack', { 21 | systemAdminRoleName: process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME, 22 | systemAdminEmail: process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL, 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/lib/saas-control-plane-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps, Tags, CfnOutput } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { ControlPlane, CognitoAuth } from "@cdklabs/sbt-aws"; 4 | import { CdkNagUtils } from '../utils/cdk-nag-utils' 5 | 6 | interface ControlPlaneStackProps extends StackProps { 7 | readonly systemAdminRoleName: string; 8 | readonly systemAdminEmail: string; 9 | } 10 | 11 | export class SaaSControlPlaneStack extends Stack { 12 | 13 | constructor(scope: Construct, id: string, props: ControlPlaneStackProps) { 14 | super(scope, id, props); 15 | 16 | // Handle CDK nag suppressions. 17 | CdkNagUtils.suppressCDKNag(this); 18 | 19 | Tags.of(this).add('saas-service', 'tenant-management'); 20 | 21 | const cognitoAuth = new CognitoAuth(this, "CognitoAuth", { 22 | systemAdminRoleName: props.systemAdminRoleName, 23 | systemAdminEmail: props.systemAdminEmail, 24 | }); 25 | 26 | const controlPlane = new ControlPlane(this, "ControlPlane", { 27 | auth: cognitoAuth, 28 | }); 29 | 30 | new CfnOutput(this, 'ControlPlaneEventBusArn', { 31 | value: controlPlane.eventManager.busArn, 32 | exportName: 'ControlPlaneEventBusArn', 33 | }); 34 | // ControlPlaneTenantDetailsTable is required to query tenants with features while doing test harness 35 | new CfnOutput(this, 'ControlPlaneTenantDetailsTable', { 36 | value: controlPlane.tables.tenantDetails.tableName, 37 | exportName: 'ControlPlaneTenantDetailsTable', 38 | }); 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-control-plane", 3 | "version": "0.1.0", 4 | "bin": { 5 | "saas-control-plane": "bin/saas-control-plane.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "jest": "^29.7.0", 17 | "ts-jest": "^29.1.2", 18 | "aws-cdk": "2.130.0", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "aws-cdk-lib": "2.130.0", 24 | "constructs": "^10.0.0", 25 | "cdk-nag": "2.28.195", 26 | "@cdklabs/sbt-aws": "0.1.5", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, []); 7 | } 8 | } -------------------------------------------------------------------------------- /Solution/saas-control-plane/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-control-plane", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /Solution/saas-control-plane/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | export CDK_PARAM_SYSTEM_ADMIN_EMAIL="$1" 4 | 5 | if [[ -z "$CDK_PARAM_SYSTEM_ADMIN_EMAIL" ]]; then 6 | echo "Please provide system admin email" 7 | exit 1 8 | fi 9 | 10 | export AWS_REGION=$(aws configure get region) 11 | if [ -z "$AWS_REGION" ]; then 12 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 13 | export AWS_REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 14 | fi 15 | echo "REGION: ${AWS_REGION}" 16 | 17 | # Preprovision base infrastructure 18 | cd ../cdk 19 | npm install 20 | 21 | npx cdk bootstrap 22 | npx cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 23 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | EMAIL="user@saascostworkshop.com" 4 | 5 | # Deploy the SaaS control plane. 6 | cd saas-control-plane/scripts 7 | ./deploy.sh "$EMAIL" 8 | cd ../../ 9 | 10 | # Deploy the application plane shared services. 11 | cd saas-app-plane/shared-services/scripts/ 12 | ./deploy.sh 1 13 | cd ../../../ 14 | 15 | # Deploy the serverless product and order microservices. 16 | cd saas-app-plane/product-service/scripts/ 17 | ./deploy.sh 1 18 | cd ../../../ 19 | 20 | # Deploy the product review service. 21 | cd saas-app-plane/product-review-service/scripts/ 22 | ./deploy.sh 1 23 | cd ../../../ 24 | 25 | # Deploy the product media stack. 26 | cd saas-app-plane/product-media-service/scripts/ 27 | ./deploy.sh 28 | cd ../../../ -------------------------------------------------------------------------------- /quicksight/modify-quicksight-role.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Get region 3 | export AWS_REGION=$(aws configure get region) 4 | if [ -z "$AWS_REGION" ]; then 5 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 6 | export AWS_REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 7 | fi 8 | echo "REGION: ${AWS_REGION}" 9 | 10 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 11 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 12 | export ROLE_NAME=CidQuickSightDataSourceRole 13 | echo "ROLE_NAME: ${ROLE_NAME}" 14 | export POLICY_NAME=TenantUsageAccess 15 | 16 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 17 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $SHARED_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 18 | echo "TENANT_USAGE_BUCKET: ${TENANT_USAGE_BUCKET}" 19 | 20 | inlinePolicy=$(cat <<-EOF 21 | { 22 | "Version": "2012-10-17", 23 | "Statement": [ 24 | { 25 | "Action": [ 26 | "glue:GetPartition", 27 | "glue:GetPartitions", 28 | "glue:GetDatabase", 29 | "glue:GetDatabases", 30 | "glue:GetTable", 31 | "glue:GetTables" 32 | ], 33 | "Resource": [ 34 | "arn:aws:glue:${AWS_REGION}:${ACCOUNT_ID}:database/tenant_daily_usage", 35 | "arn:aws:glue:${AWS_REGION}:${ACCOUNT_ID}:table/tenant_daily_usage/*" 36 | ], 37 | "Effect": "Allow", 38 | "Sid": "AllowGlueTenantUsage" 39 | }, 40 | { 41 | "Action": "s3:ListBucket", 42 | "Resource": [ 43 | "arn:aws:s3:::${TENANT_USAGE_BUCKET}" 44 | ], 45 | "Effect": "Allow", 46 | "Sid": "AllowListBucket" 47 | }, 48 | { 49 | "Action": [ 50 | "s3:GetObject", 51 | "s3:GetObjectVersion" 52 | ], 53 | "Resource": [ 54 | "arn:aws:s3:::${TENANT_USAGE_BUCKET}/*" 55 | ], 56 | "Effect": "Allow", 57 | "Sid": "AllowReadBucket" 58 | } 59 | ] 60 | } 61 | EOF 62 | ) 63 | 64 | #add a policy to the role using aws cli 65 | aws iam put-role-policy \ 66 | --role-name $ROLE_NAME \ 67 | --policy-name $POLICY_NAME \ 68 | --policy-document "${inlinePolicy}" 69 | 70 | -------------------------------------------------------------------------------- /saas-app-plane/copy-solution.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # Script to copy completed code from solutions folder to workspace directories. 4 | 5 | # Shared services. 6 | cp ../Solution/saas-app-plane/shared-services/src/tenant_authorizer.py shared-services/src 7 | 8 | # Product service. 9 | cp ../Solution/saas-app-plane/product-service/src/dal/product_service_dal.py product-service/src/dal 10 | cp ../Solution/saas-app-plane/product-service/src/product_service.py product-service/src 11 | cp ../Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetry_service.py product-service/src/extensions/telemetry-api/telemetry_api_extension 12 | cp ../Solution/saas-app-plane/product-service/src/fine_grained_aggregator.py product-service/src 13 | 14 | # Product review service. 15 | cp ../Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/ecs-usage-aggregator.py product-review-service/src/lambdas-aggregator 16 | 17 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/bin/product-media-service-app.ts: -------------------------------------------------------------------------------- 1 | import { Aspects, App } from 'aws-cdk-lib'; 2 | import { ApplicationPlaneStack } from '../lib/application-plane-stack'; 3 | import { TenantProvisionStack } from '../lib/tenant-provision-stack'; 4 | import { AwsSolutionsChecks } from 'cdk-nag' 5 | 6 | const app = new App(); 7 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 8 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 9 | 10 | // Environment variables. 11 | const accountId = process.env.ACCOUNT_ID; 12 | const region = process.env.REGION; 13 | const tenantId = process.env.TENANT_ID; 14 | const listenerRulePriorityBase = Number(process.env.PRIORITY_BASE); 15 | 16 | // Deploy base application stack. 17 | const env = {account: accountId, region: region} 18 | 19 | // Deploy tenant stack. 20 | if (tenantId && !isNaN(listenerRulePriorityBase)) { 21 | const stackName = `ProductMediaTenantStack-${tenantId}`; 22 | new TenantProvisionStack(app, stackName, {tenantId, listenerRulePriorityBase, env}); 23 | } else { 24 | new ApplicationPlaneStack(app, 'ProductMediaAppStack', {env}); 25 | } 26 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=891377098679:region=us-west-1": [ 3 | "us-west-1a", 4 | "us-west-1b" 5 | ], 6 | "availability-zones:account=230165232954:region=us-east-1": [ 7 | "us-east-1a", 8 | "us-east-1b", 9 | "us-east-1c", 10 | "us-east-1d", 11 | "us-east-1e", 12 | "us-east-1f" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "build": "tsc", 6 | "watch": "tsc -w", 7 | "test": "jest", 8 | "cdk": "cdk" 9 | }, 10 | "devDependencies": { 11 | "@types/jest": "^29.5.12", 12 | "@types/node": "20.11.19", 13 | "aws-cdk": "2.130.0", 14 | "jest": "^29.7.0", 15 | "ts-jest": "^29.1.2", 16 | "ts-node": "^10.9.2", 17 | "typescript": "~5.3.3" 18 | }, 19 | "dependencies": { 20 | "@aws-cdk/aws-lambda-python-alpha": "2.146.0-alpha.0", 21 | "aws-cdk-lib": "2.146.0", 22 | "cdk-nag": "2.28.195", 23 | "constructs": "^10.0.0", 24 | "source-map-support": "^0.5.21" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Server from '../lib/server-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/server-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Server.ServerStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "types": ["node", "jest"], 6 | "lib": [ 7 | "es2020", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "include": [ 29 | "bin/**/*.ts", 30 | "lib/**/*.ts" 31 | ], 32 | "exclude": [ 33 | "node_modules", 34 | "cdk.out" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-EC26', 13 | reason: 'EBS encryption unnecessary.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 18 | }, 19 | { 20 | id: 'AwsSolutions-VPC7', 21 | reason: 'VPC Flow logs unnecessary for current implementation, relying on access log from API Gateway.' 22 | }, 23 | { 24 | id: 'AwsSolutions-AS3', 25 | reason: 'Notifications not required for Auto Scaling Group.' 26 | }, 27 | { 28 | id: 'AwsSolutions-L1', 29 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 30 | }, 31 | { 32 | id: 'AwsSolutions-COG4', 33 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 34 | }, 35 | { 36 | id: 'AwsSolutions-SNS2', 37 | reason: 'Not utilizing SNS notifications.' 38 | }, 39 | { 40 | id: 'AwsSolutions-SNS3', 41 | reason: 'Not utilizing SNS notifications.' 42 | }, 43 | { 44 | id: 'AwsSolutions-S1', 45 | reason: 'Server access logs not required for S3 buckets.' 46 | }, 47 | { 48 | id: 'AwsSolutions-S10', 49 | reason: 'SSL not required for S3 buckets collecting access logs.' 50 | }, 51 | { 52 | id: 'AwsSolutions-EC23', 53 | reason: 'Security group rules are restricted to allow only necessary traffic (port 80 or 443) from ALB.' 54 | }, 55 | { 56 | id: 'AwsSolutions-ECS2', 57 | reason: 'Environment variables permitted in container definitions.' 58 | }, 59 | { 60 | id: 'AwsSolutions-ECS7', 61 | reason: 'Container logging not required.' 62 | }, 63 | 64 | ]); 65 | } 66 | } -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/deploy-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | echo "AWS_REGION: ${AWS_REGION}" 8 | export REGION=$AWS_REGION 9 | echo "REGION: ${REGION}" 10 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 11 | echo "Account ID: ${ACCOUNT_ID}" 12 | 13 | TENANT_ID=$1 14 | export TENANT_ID 15 | echo "Provision tenant: ${TENANT_ID}" 16 | MEDIA_SERVICES_STACK_NAME='ProductMediaAppStack' 17 | APPLICATION_PLANE_LISTENER_ARN=$(aws cloudformation describe-stacks --stack-name $MEDIA_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?OutputKey=='ApplicationPlaneListenerArn'].OutputValue" --output text) 18 | HIGHEST_PRIORITY_BASE=$(aws elbv2 describe-rules --listener-arn $APPLICATION_PLANE_LISTENER_ARN --query 'Rules[*].Priority' --output text | tr '\t' '\n' | grep -v default | sort -rn | head -1) 19 | echo "HIGHEST_PRIORITY_BASE is from listener: $HIGHEST_PRIORITY_BASE" 20 | # Check if HIGHEST_PRIORITY_BASE is empty or not a number 21 | if [ -z "$HIGHEST_PRIORITY_BASE" ] || ! [[ "$HIGHEST_PRIORITY_BASE" =~ ^[0-9]+$ ]]; then 22 | HIGHEST_PRIORITY_BASE=10 23 | echo "PRIORITY_BASE was empty or not a number. Setting it to default value: $HIGHEST_PRIORITY_BASE" 24 | fi 25 | echo "HIGHEST_PRIORITY_BASE is now: $HIGHEST_PRIORITY_BASE" 26 | export PRIORITY_BASE=$(($HIGHEST_PRIORITY_BASE + 10)) 27 | echo "Deploying stack for $TENANT_ID with listener rule priority base $PRIORITY_BASE" 28 | 29 | cd ../cdk 30 | npm install 31 | # Executing the ApplicationPlaneStack CDK stack to create ECS Cluster, ALB, S3 Bucket, ECR, Parameter Store, APIGW Resource 32 | npx cdk deploy "ProductMediaTenantStack-${TENANT_ID}" --require-approval never --concurrency 10 --asset-parallelism true 33 | 34 | cd ../scripts 35 | echo $PRIORITY_BASE -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 4 | export REGION=$(aws configure get region) 5 | if [ -z "$REGION" ]; then 6 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 7 | export REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 8 | fi 9 | echo "REGION: ${REGION}" 10 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 11 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 12 | 13 | cd ../cdk 14 | echo ${PWD} 15 | 16 | npm install 17 | npm run build 18 | 19 | # Executing the ApplicationPlaneStack CDK stack to create ECS Cluster, ALB, S3 Bucket, ECR, Parameter Store, APIGW Resource 20 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 21 | 22 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $SHARED_SERVICES_STACK_NAME --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 23 | # Create a prefix in TENANT_USAGE_BUCKET 24 | aws s3api put-object --bucket $TENANT_USAGE_BUCKET --key s3_storage_lens_report/ 25 | 26 | 27 | # Building the code and preparing Product Media Docker Image 28 | cd ../src 29 | docker build --platform linux/amd64 -f resources/dockerfile -t product-media-service:latest . 30 | 31 | # Login to ECR 32 | aws ecr get-login-password --region $REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com 33 | 34 | # Tag the image and push it to product-media-service ECR repo 35 | docker tag product-media-service:latest $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/product-media-service:latest 36 | docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/product-media-service:latest 37 | 38 | API_ID=$( 39 | aws cloudformation describe-stacks \ 40 | --stack-name $SHARED_SERVICES_STACK_NAME \ 41 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 42 | --output text 43 | ) 44 | echo "API_ID: $API_ID" 45 | 46 | # Re-Deploy API in Prod stage 47 | aws apigateway create-deployment \ 48 | --rest-api-id "$API_ID" \ 49 | --stage-name prod \ 50 | --description "Product Service services deployment." 51 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/last_listener_rule_priority_base.txt: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/resources/test-audio.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/product-media-service/scripts/resources/test-audio.mp4 -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/resources/test-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/product-media-service/scripts/resources/test-image.png -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/resources/test-text.txt: -------------------------------------------------------------------------------- 1 | This is workshop for Cost Visibility and Attribution in Multi-Tenant SaaS Environments -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 9 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 10 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 11 | 12 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 13 | 14 | echo "Checking if the results were saved in S3" 15 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/src/resources/dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image as the base image 2 | FROM python:3.9 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file first to leverage Docker cache 8 | COPY /resources/requirements.txt /app/resources/requirements.txt 9 | 10 | # Install any required Python packages 11 | RUN pip install --no-cache-dir --trusted-host pypi.python.org -r /app/resources/requirements.txt 12 | 13 | # Copy the rest of the application code to the container 14 | COPY . /app 15 | 16 | # Expose the port the app runs on (if applicable) 17 | EXPOSE 80 18 | 19 | # Set the entrypoint for the container 20 | CMD ["python", "/app/product_media.py"] -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/src/resources/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.3 2 | boto3 3 | python-jose[cryptography] -------------------------------------------------------------------------------- /saas-app-plane/product-media-service/src/test.txt: -------------------------------------------------------------------------------- 1 | 2 | This is a test file needed to successfully run the unit test of Product Media Service -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk.out/synth.lock: -------------------------------------------------------------------------------- 1 | 51643 -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/bin/product-review-app.ts: -------------------------------------------------------------------------------- 1 | import { Aspects, App } from 'aws-cdk-lib'; 2 | import { ApplicationPlaneStack } from '../lib/application-plane-stack'; 3 | import { ECSServiceStack } from '../lib/ecs-service-stack'; 4 | import { TenantProvisioningStack } from '../lib/tenant-provisioning-stack'; 5 | import { UsageAggregator } from '../lib/UsageAggregatorConstruct'; 6 | import { AwsSolutionsChecks } from 'cdk-nag' 7 | 8 | const app = new App(); 9 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 10 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 11 | 12 | // Read environment variables 13 | const awsAccountId = process.env.AWS_ACCOUNT_ID; 14 | const awsRegion = process.env.AWS_REGION; 15 | 16 | const env = { account: awsAccountId, region: awsRegion }; 17 | 18 | // Create app plane 19 | new ApplicationPlaneStack(app, 'ProductReviewAppStack', {env}); 20 | 21 | const imageVersion = app.node.tryGetContext('imageVersion'); 22 | 23 | // Add ECS service, build the image before this 24 | new ECSServiceStack(app, 'ProductReviewECSServiceStack', { 25 | imageVersion: imageVersion, 26 | listenerRulePriorityBase: 100 27 | }); 28 | 29 | const tenantId = app.node.tryGetContext('tenantId'); 30 | 31 | new TenantProvisioningStack(app, `ProductReviewTenantProvisioningStack-${tenantId}`, {tenantId}) 32 | 33 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=686790399189:region=ap-northeast-1": [ 3 | "ap-northeast-1a", 4 | "ap-northeast-1c", 5 | "ap-northeast-1d" 6 | ], 7 | "vpc-provider:account=686790399189:filter.vpc-id=vpc-0b1fd9a7b0c4bf397:region=ap-northeast-1:returnAsymmetricSubnets=true": { 8 | "vpcId": "vpc-0b1fd9a7b0c4bf397", 9 | "vpcCidrBlock": "10.10.0.0/16", 10 | "ownerAccountId": "686790399189", 11 | "availabilityZones": [], 12 | "subnetGroups": [ 13 | { 14 | "name": "private", 15 | "type": "Private", 16 | "subnets": [ 17 | { 18 | "subnetId": "subnet-0a6643b55ae1fba78", 19 | "cidr": "10.10.0.0/18", 20 | "availabilityZone": "ap-northeast-1a", 21 | "routeTableId": "rtb-06deda6371ea28e80" 22 | }, 23 | { 24 | "subnetId": "subnet-0e5feefc1f183eabb", 25 | "cidr": "10.10.64.0/18", 26 | "availabilityZone": "ap-northeast-1c", 27 | "routeTableId": "rtb-031b225979955aa98" 28 | } 29 | ] 30 | }, 31 | { 32 | "name": "public", 33 | "type": "Public", 34 | "subnets": [ 35 | { 36 | "subnetId": "subnet-0241d9789134756f4", 37 | "cidr": "10.10.192.0/24", 38 | "availabilityZone": "ap-northeast-1a", 39 | "routeTableId": "rtb-05a38a8a2290e690c" 40 | }, 41 | { 42 | "subnetId": "subnet-0b4cfc04a4432db5b", 43 | "cidr": "10.10.193.0/24", 44 | "availabilityZone": "ap-northeast-1c", 45 | "routeTableId": "rtb-04b2f1e4d5593f251" 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "product-review-service", 3 | "version": "1.0.0", 4 | "description": "A service for managing product reviews", 5 | "scripts": { 6 | "build": "tsc", 7 | "watch": "tsc -w", 8 | "test": "jest", 9 | "cdk": "cdk" 10 | }, 11 | "dependencies": { 12 | "aws-cdk-lib": "2.154.1", 13 | "constructs": "^10.0.0", 14 | "cdk-nag": "2.28.195", 15 | "source-map-support": "^0.5.21", 16 | "@aws-cdk/aws-lambda-python-alpha": "2.154.1-alpha.0" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^29.5.12", 20 | "@types/node": "20.11.30", 21 | "aws-cdk": "2.154.1", 22 | "jest": "^29.7.0", 23 | "ts-jest": "^29.1.2", 24 | "ts-node": "^10.9.2", 25 | "typescript": "~5.2.2" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-VPC7', 13 | reason: 'VPC Flow logs unnecessary for current implementation, relying on access log from API Gateway.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix for AWS Managed policies.' 18 | }, 19 | { 20 | id: 'AwsSolutions-EC26', 21 | reason: 'EBS encryption unnecessary.' 22 | }, 23 | { 24 | id: 'AwsSolutions-AS3', 25 | reason: 'Notifications not required for Auto Scaling Group.' 26 | }, 27 | { 28 | id: 'AwsSolutions-L1', 29 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 30 | }, 31 | { 32 | id: 'AwsSolutions-SNS2', 33 | reason: 'Not utilizing SNS notifications.' 34 | }, 35 | { 36 | id: 'AwsSolutions-SNS3', 37 | reason: 'Not utilizing SNS notifications.' 38 | }, 39 | { 40 | id: 'AwsSolutions-S1', 41 | reason: 'Server access logs not required for S3 buckets.' 42 | }, 43 | { 44 | id: 'AwsSolutions-S10', 45 | reason: 'SSL not required for S3 buckets collecting access logs.' 46 | }, 47 | { 48 | id: 'AwsSolutions-COG4', 49 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 50 | }, 51 | { 52 | id: 'AwsSolutions-RDS10', 53 | reason: 'Deletion protection not required for RDS DB.' 54 | }, 55 | { 56 | id: 'AwsSolutions-ECS2', 57 | reason: 'Environment variables permitted in container definitions.' 58 | }, 59 | { 60 | id: 'AwsSolutions-EC23', 61 | reason: 'Configuration restricted to allow only necessary traffic (port 80 or 443) from ALB.' 62 | }, 63 | { 64 | id: 'AwsSolutions-SMG4', 65 | reason: 'Automatic rotation not necessary.' 66 | }, 67 | { 68 | id: 'AwsSolutions-RDS6', 69 | reason: 'Fine-grained access control methods in place to restrict tenant access.' 70 | }, 71 | ]); 72 | } 73 | } -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/scripts/deploy-tenant.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | if [ -z "$1" ]; then 4 | echo "Usage: $0 " 5 | exit 1 6 | fi 7 | 8 | echo "AWS_REGION: ${AWS_REGION}" 9 | export REGION=$AWS_REGION 10 | echo "REGION: ${REGION}" 11 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 12 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 13 | 14 | TENANT_ID=$1 15 | echo "Provision tenant: ${TENANT_ID}" 16 | 17 | cd ../cdk 18 | npm install 19 | echo ${PWD} 20 | 21 | npx cdk deploy "ProductReviewTenantProvisioningStack-$TENANT_ID" --app "npx ts-node bin/product-review-app.ts" \ 22 | --context tenantId=$TENANT_ID \ 23 | --require-approval never -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | [[ -z "$1" ]] && { 4 | cp ../../../Solution/saas-app-plane/product-review-service/src/lambdas-aggregator/ecs-usage-aggregator.py ../src/lambdas-aggregator 5 | } 6 | 7 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 8 | 9 | export AWS_REGION=$(aws configure get region) 10 | if [ -z "$AWS_REGION" ]; then 11 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 12 | export AWS_REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 13 | fi 14 | echo "REGION: ${AWS_REGION}" 15 | export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) 16 | echo "ACCOUNT_ID: ${ACCOUNT_ID}" 17 | 18 | 19 | cd ../cdk 20 | echo ${PWD} 21 | 22 | npm install 23 | npm run build 24 | 25 | npx cdk deploy "ProductReviewAppStack" --app "npx ts-node bin/product-review-app.ts" --require-approval never 26 | 27 | IMAGE_VERSION=$(date +%s) 28 | echo "IMAGE_VERSION: ${IMAGE_VERSION}" 29 | 30 | cd ../src 31 | 32 | docker build --platform linux/amd64 -f resources/Dockerfile -t product-review-service:$IMAGE_VERSION . 33 | 34 | # Login to ECR 35 | aws ecr get-login-password --region $AWS_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com 36 | 37 | # Tag the image and push it to product-service ECR repo 38 | docker tag product-review-service:$IMAGE_VERSION $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-review-service:$IMAGE_VERSION 39 | 40 | docker push $ACCOUNT_ID.dkr.ecr.$AWS_REGION.amazonaws.com/product-review-service:$IMAGE_VERSION 41 | 42 | 43 | cd ../cdk 44 | 45 | npx cdk deploy "ProductReviewECSServiceStack" --app "npx ts-node bin/product-review-app.ts" \ 46 | --context imageVersion=$IMAGE_VERSION \ 47 | --require-approval never 48 | 49 | cd ../scripts 50 | 51 | API_ID=$( 52 | aws cloudformation describe-stacks \ 53 | --stack-name $SHARED_SERVICES_STACK_NAME \ 54 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 55 | --output text 56 | ) 57 | echo "API_ID: $API_ID" 58 | 59 | # Deploy API for good measure. 60 | aws apigateway create-deployment \ 61 | --rest-api-id "$API_ID" \ 62 | --stage-name prod \ 63 | --description "Product review services deployment." 64 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/scripts/out.json: -------------------------------------------------------------------------------- 1 | {"statusCode": 200, "body": "Data from pg_stat_statements uploaded to S3 at s3://sharedservicesstack-tenantusagebucket3870349d-gmnu1f34jnzb/fine_grained/year=2024/month=10/product-review-aurora_dbload_by_tenant-usage_by_tenant-10-27-2024.json"} -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | PRODUCT_REVIEW_APP_STACK=ProductReviewAppStack 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | FINE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='ECSUsageAggregatorLambda'].OutputValue" --output text) 9 | RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSPerformanceInsightsLambda'].OutputValue" --output text) 10 | RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSIopsUsageLambda'].OutputValue" --output text) 11 | RDS_AURORA_STORAGE_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $PRODUCT_REVIEW_APP_STACK --query "Stacks[0].Outputs[?ExportName=='RDSStorageUsageLambda'].OutputValue" --output text) 12 | 13 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 14 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 15 | echo "Fine Grained Aggregator: $FINE_GRAINED_AGGREGATOR" 16 | echo "RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR: $RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR" 17 | echo "RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR: $RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR" 18 | echo "RDS_AURORA_STORAGE_AGGREGATOR: $RDS_AURORA_STORAGE_AGGREGATOR" 19 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 20 | 21 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 22 | aws lambda invoke --function-name $FINE_GRAINED_AGGREGATOR out.json 23 | aws lambda invoke --function-name $RDS_AURORA_IOPS_EXECUTION_TIME_AGGREGATOR out.json 24 | aws lambda invoke --function-name $RDS_AURORA_STORAGE_AGGREGATOR out.json 25 | aws lambda invoke --function-name $RDS_PERFORMANCE_INSIGHTS_DB_LOAD_AGGREGATOR --cli-read-timeout 600 --cli-connect-timeout 600 out.json 26 | 27 | 28 | echo "Checking if the results were saved in S3" 29 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ 30 | aws s3 ls s3://$TENANT_USAGE_BUCKET/fine_grained/ -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/cdk.out/synth.lock: -------------------------------------------------------------------------------- 1 | 56409 -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-aggregator/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-aggregator/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | psycopg[binary,pool] 3 | jsonpickle 4 | simplejson -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-aggregator/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/product-review-service/src/lambdas-aggregator/utils/__init__.py -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-rds/db-schema.sql: -------------------------------------------------------------------------------- 1 | -- Create a new schema 2 | CREATE SCHEMA app; 3 | 4 | -- Create a new table in the new schema 5 | CREATE TABLE app.product_reviews ( 6 | review_id text PRIMARY KEY, 7 | order_id INTEGER NOT NULL , 8 | product_id INTEGER NOT NULL , 9 | rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), 10 | review_description text NOT NULL, 11 | tenant_id TEXT NOT NULL, 12 | review_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 13 | CONSTRAINT review_order_unique UNIQUE (review_id, order_id, product_id) 14 | ); 15 | 16 | -- enable RLS on pooled table 17 | ALTER TABLE app.product_reviews ENABLE ROW LEVEL SECURITY; 18 | CREATE POLICY tenant_user_isolation_policy ON app.product_reviews 19 | USING (tenant_id::TEXT = current_user); 20 | 21 | -- enable pg_stat_statements 22 | CREATE EXTENSION pg_stat_statements; 23 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-rds/rds-tenant-provision.sql: -------------------------------------------------------------------------------- 1 | -- Create a new user 2 | CREATE USER "" WITH PASSWORD ''; 3 | 4 | -- Grant permissions to tenant 5 | 6 | GRANT CONNECT ON DATABASE "" TO ""; 7 | GRANT USAGE ON SCHEMA app TO ""; 8 | GRANT ALL PRIVILEGES ON table app.product_reviews TO ""; 9 | 10 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-rds/rds-tenant.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | import psycopg 4 | import json 5 | 6 | secrets_manager = boto3.client('secretsmanager') 7 | creds_secret_name = os.getenv('DB_CRED_SECRET_NAME') 8 | db_name = os.getenv('DB_NAME') 9 | 10 | def handler(event, context): 11 | try: 12 | 13 | tenant_state = event.get('tenantState') 14 | tenant_id = event.get('tenantId') 15 | tenant_secret_name = event.get('tenantSecretName') 16 | 17 | password, username, host, port = get_secret_value(creds_secret_name) 18 | tenant_password, tenant_username, tenant_host, tenant_port = get_secret_value(tenant_secret_name) 19 | 20 | connection = psycopg.connect(dbname=db_name, 21 | host=host, 22 | port=port, 23 | user=username, 24 | password=password, 25 | autocommit=True) 26 | 27 | if tenant_state == 'PROVISION': 28 | with open(os.path.join(os.path.dirname(__file__), 'rds-tenant-provision.sql'), 'r') as f: 29 | sql_script = f.read() 30 | 31 | sql_script = sql_script.replace("",tenant_id).replace("", tenant_password).replace("", db_name) 32 | 33 | print(sql_script) 34 | 35 | query(connection, sql_script) 36 | elif tenant_state == 'DE-PROVISION': 37 | query(connection, "REVOKE CONNECT ON DATABASE {0} FROM {1};".format(db_name, tenant_id)) 38 | query(connection, "REVOKE USAGE ON SCHEMA app FROM {0};".format(tenant_id)) 39 | query(connection, "REVOKE ALL PRIVILEGES ON table app.product_reviews FROM {0};".format(tenant_id)) 40 | query(connection, "DROP user {0};".format(tenant_id)) 41 | 42 | connection.close() 43 | 44 | return { 45 | 'status': 'OK', 46 | 'results': "Tenant Initialized" 47 | } 48 | except Exception as err: 49 | return { 50 | 'status': 'ERROR', 51 | 'err': str(err), 52 | 'message': str(err) 53 | } 54 | 55 | def query(connection, sql): 56 | connection.execute(sql) 57 | 58 | def get_secret_value(secret_id): 59 | response = secrets_manager.get_secret_value(SecretId=secret_id) 60 | 61 | #convert string to json 62 | secret_value = json.loads(response['SecretString']) 63 | 64 | password = secret_value["password"] 65 | username = secret_value["username"] 66 | host = secret_value["host"] 67 | port = secret_value["port"] 68 | 69 | return password, username, host, port 70 | 71 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-rds/rds.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | import os 3 | import psycopg 4 | import json 5 | 6 | secrets_manager = boto3.client('secretsmanager') 7 | 8 | def handler(event, context): 9 | try: 10 | creds_secret_name = os.getenv('DB_CRED_SECRET_NAME') 11 | db_name = os.getenv('DB_NAME') 12 | 13 | password, username, host, port = get_secret_value(creds_secret_name) 14 | 15 | connection = psycopg.connect(dbname='postgres', 16 | host=host, 17 | port=port, 18 | user=username, 19 | password=password, 20 | autocommit=True) 21 | query(connection, "CREATE DATABASE {0};".format(db_name)) 22 | connection.close() 23 | 24 | connection = psycopg.connect(dbname=db_name, 25 | host=host, 26 | port=port, 27 | user=username, 28 | password=password, 29 | autocommit=True) 30 | 31 | 32 | with open(os.path.join(os.path.dirname(__file__), 'db-schema.sql'), 'r') as f: 33 | sql_script = f.read() 34 | print(sql_script) 35 | query(connection, sql_script) 36 | connection.close() 37 | 38 | return { 39 | 'status': 'OK', 40 | 'results': "RDS Initialized" 41 | } 42 | except Exception as err: 43 | return { 44 | 'status': 'ERROR', 45 | 'err': str(err), 46 | 'message': str(err) 47 | } 48 | 49 | def query(connection, sql): 50 | connection.execute(sql) 51 | 52 | def get_secret_value(secret_id): 53 | response = secrets_manager.get_secret_value(SecretId=secret_id) 54 | 55 | #convert string to json 56 | secret_value = json.loads(response['SecretString']) 57 | 58 | password = secret_value["password"] 59 | username = secret_value["username"] 60 | host = secret_value["host"] 61 | port = secret_value["port"] 62 | 63 | return password, username, host, port 64 | 65 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/lambdas-rds/requirements.txt: -------------------------------------------------------------------------------- 1 | psycopg[binary,pool] -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/resources/Dockerfile: -------------------------------------------------------------------------------- 1 | # Use the official Python image as the base image 2 | FROM public.ecr.aws/docker/library/python:3.12-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Copy the requirements file first to leverage Docker cache 8 | COPY /resources/requirements.txt /app/resources/requirements.txt 9 | 10 | # Install any required Python packages 11 | RUN pip install --no-cache-dir --trusted-host pypi.python.org -r /app/resources/requirements.txt 12 | 13 | # Copy the rest of the application code to the container 14 | COPY review-service /app/review-service 15 | 16 | # Expose the port the app runs on (if applicable) 17 | EXPOSE 80 18 | 19 | # Set the entrypoint for the container 20 | CMD ["python", "/app/review-service/product_review_service.py"] -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/resources/requirements.txt: -------------------------------------------------------------------------------- 1 | flask==3.0.3 2 | boto3 3 | psycopg2-binary==2.9.9 4 | aws-embedded-metrics==3.2.0 5 | python-jose[cryptography] -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/review-service/product_review_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import json 4 | from aws_embedded_metrics import metric_scope 5 | import logging 6 | 7 | logger = logging.getLogger('review_svc.logger') 8 | logger.setLevel(logging.DEBUG) 9 | 10 | @metric_scope 11 | async def create_emf_log(tenant_id, metric_name, metric_value, metric_unit, metrics): 12 | logger.info(f"inside emf logging...") 13 | try: 14 | 15 | metrics.put_dimensions({"Tenant": tenant_id}) 16 | metrics.put_metric(metric_name, metric_value, metric_unit) 17 | await metrics.flush() 18 | except Exception as e: 19 | logger.error(f"Error creating EMF log: {e}") 20 | 21 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/review-service/product_review_model.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | # Reviews class with product reviews attributes 5 | class Reviews: 6 | def __init__(self, review_id, product_id, order_id, rating, review_description, tenant_id): 7 | self.review_id = review_id 8 | self.product_id = product_id 9 | self.order_id = order_id 10 | self.rating = rating 11 | self.review_description = review_description 12 | self.tenant_id = tenant_id 13 | self.review_date = None 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /saas-app-plane/product-review-service/src/review-service/utils.py: -------------------------------------------------------------------------------- 1 | # Define the custom DatabaseError exception 2 | class DatabaseError(Exception): 3 | def __init__(self, error_message, error_code): 4 | self.error_message = error_message 5 | self.error_code = error_code 6 | super().__init__(f"Database error: {error_message}, Code: {error_code}") 7 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/bin/serverless-saas-app.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { ServerlessSaaSAppStack } from '../lib/serverless-saas-app-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 9 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 10 | 11 | new ServerlessSaaSAppStack(app, 'ServerlessSaaSAppStack', {}); -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/interfaces/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/serverless-saas-app.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "aws-cdk": "2.130.0", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.2", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "^2.146.0-alpha.0", 24 | "aws-cdk-lib": "^2.146.0", 25 | "cdk-nag": "2.28.195", 26 | "constructs": "^10.0.0", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "types": ["node", "jest"], 6 | "lib": [ 7 | "es2020", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "noImplicitThis": true, 15 | "alwaysStrict": true, 16 | "noUnusedLocals": false, 17 | "noUnusedParameters": false, 18 | "noImplicitReturns": true, 19 | "noFallthroughCasesInSwitch": false, 20 | "inlineSourceMap": true, 21 | "inlineSources": true, 22 | "experimentalDecorators": true, 23 | "strictPropertyInitialization": false, 24 | "typeRoots": [ 25 | "./node_modules/@types" 26 | ] 27 | }, 28 | "exclude": [ 29 | "node_modules", 30 | "cdk.out" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-IAM4', 9 | reason: 'AWS Managed policies are permitted.' 10 | }, 11 | { 12 | id: 'AwsSolutions-IAM5', 13 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 14 | }, 15 | { 16 | id: 'AwsSolutions-L1', 17 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 18 | }, 19 | { 20 | id: 'AwsSolutions-APIG4', 21 | reason: 'Custom request authorizer is being used.' 22 | }, 23 | { 24 | id: 'AwsSolutions-COG4', 25 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 26 | }, 27 | ]); 28 | } 29 | } -------------------------------------------------------------------------------- /saas-app-plane/product-service/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | [[ -z "$1" ]] && { 4 | cp ../../../Solution/saas-app-plane/product-service/src/dal/product_service_dal.py ../src/dal 5 | cp ../../../Solution/saas-app-plane/product-service/src/product_service.py ../src 6 | cp ../../../Solution/saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetry_service.py ../src/extensions/telemetry-api/telemetry_api_extension 7 | cp ../../../Solution/saas-app-plane/product-service/src/fine_grained_aggregator.py ../src 8 | } 9 | 10 | pip install pylint 11 | 12 | SHARED_SERVICES_STACK_NAME='SharedServicesStack' 13 | 14 | cd ../src 15 | #python3 -m pylint -E -d E0401 $(find . -iname "*.py" -not -path "./.aws-sam/*" -not -path "./extensions/*") 16 | 17 | # Build extension. 18 | cd extensions/telemetry-api 19 | chmod +x telemetry_api_extension/extension.py 20 | pip3 install -r telemetry_api_extension/requirements.txt -t ./telemetry_api_extension/ 21 | 22 | chmod +x extensions/telemetry_api_extension 23 | # Check if we're on Windows 24 | if [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* ]]; then 25 | echo "Running on Windows, using 7-Zip" 26 | 7z a -tzip extension.zip extensions telemetry_api_extension 27 | else 28 | zip -r extension.zip extensions telemetry_api_extension 29 | fi 30 | 31 | 32 | cd ../../../cdk 33 | npm install 34 | npm run build 35 | 36 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 37 | 38 | # Deploy API services to stage. 39 | API_ID=$( 40 | aws cloudformation describe-stacks \ 41 | --stack-name $SHARED_SERVICES_STACK_NAME \ 42 | --query "Stacks[0].Outputs[?contains(OutputKey,'AppPlaneApiGatewayId')].OutputValue" \ 43 | --output text 44 | ) 45 | echo "API_ID: $API_ID" 46 | 47 | # Deploy API for good measure. 48 | aws apigateway create-deployment \ 49 | --rest-api-id "$API_ID" \ 50 | --stage-name prod \ 51 | --description "Product services deployment." 52 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/scripts/out.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /saas-app-plane/product-service/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SERVERLESS=ServerlessSaaSAppStack 4 | STACK_NAME_SHAREDINFRA=SharedServicesStack 5 | echo "Testing Usage Aggregator Service" 6 | 7 | FINE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SERVERLESS --query "Stacks[0].Outputs[?ExportName=='FineGrainedUsageAggregatorLambda'].OutputValue" --output text) 8 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 9 | echo "Fine Grained Aggregator: $FINE_GRAINED_AGGREGATOR" 10 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 11 | 12 | aws lambda invoke --function-name $FINE_GRAINED_AGGREGATOR out.json 13 | 14 | echo "Checking if the results were saved in S3" 15 | aws s3 ls s3://$TENANT_USAGE_BUCKET/fine_grained/ -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/extensions/telemetry_api_extension: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | set -euo pipefail 6 | 7 | OWN_FILENAME="$(basename $0)" 8 | LAMBDA_EXTENSION_NAME="$OWN_FILENAME" # (external) extension name has to match the filename 9 | 10 | echo "${LAMBDA_EXTENSION_NAME} launching extension" 11 | python3 "/opt/${LAMBDA_EXTENSION_NAME}/extension.py" -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/extension.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import sys 5 | import time 6 | 7 | from pathlib import Path 8 | from queue import Queue 9 | from extensions_api_client import register_extension, next 10 | from telemetry_http_listener import start_http_listener 11 | from telemetery_api_client import subscibe_listener 12 | from telemetry_dispatcher import dispatch_telmetery 13 | 14 | def main(): 15 | print("Starting the Telemetry API Extension", flush=True) 16 | 17 | extension_name = Path(__file__).parent.name 18 | print("Extension Main: Registring the extension using extension name: {0}".format(extension_name), flush=True) 19 | extension_id = register_extension(extension_name) 20 | 21 | print("Extension Main: Starting the http listener which will receive data from Telemetry API", flush=True) 22 | queue = Queue() 23 | listener_url = start_http_listener(queue) 24 | 25 | print("Extension Main: Subscribing the listener to TelemetryAPI", flush=True) 26 | subscibe_listener(extension_id, listener_url) 27 | 28 | while True: 29 | print("Extension Main: Next", flush=True) 30 | 31 | event_data = next(extension_id) 32 | 33 | if event_data["eventType"] == "INVOKE": 34 | print ("Extension Main: Handle Invoke Event", flush=True) 35 | dispatch_telmetery(queue, False) 36 | elif event_data["eventType"] == "SHUTDOWN": 37 | # Wait for 1 sec to receive remaining events 38 | # nosem 39 | time.sleep(1) 40 | 41 | print ("Extension Main: Handle Shutdown Event", flush=True) 42 | dispatch_telmetery(queue, True) 43 | sys.exit(0) 44 | 45 | if __name__ == "__main__": 46 | main() 47 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/requirements.txt: -------------------------------------------------------------------------------- 1 | boto3==1.34.131 2 | botocore==1.34.131 3 | urllib3==1.26.15 4 | requests -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetery_api_client.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import os 5 | from re import T 6 | import requests 7 | import json 8 | 9 | TELEMETRY_API_URL = "http://{0}/2022-07-01/telemetry".format(os.getenv("AWS_LAMBDA_RUNTIME_API")) 10 | LAMBDA_EXTENSION_IDENTIFIER_HEADER_KEY = "Lambda-Extension-Identifier" 11 | 12 | TIMEOUT_MS = 1000; # Maximum time (in milliseconds) that a batch is buffered. 13 | MAX_BYTES = 256*1024; # Maximum size in bytes that the logs are buffered in memory. 14 | MAX_ITEMS = 10000; # Maximum number of events that are buffered in memory. 15 | 16 | def subscibe_listener(extension_id, listener_url): 17 | print ("[telemetry_api_client.subscibe_listener] Subscribing Extension to receive telemetry data. ExtenionsId: {0}, listener url: {1}, telemetry api url: {2}".format(extension_id, listener_url, TELEMETRY_API_URL)) 18 | 19 | try: 20 | subscription_request_body = { 21 | "schemaVersion": "2022-07-01", 22 | "destination": { 23 | "protocol": "HTTP", 24 | "URI": listener_url, 25 | }, 26 | "types": ["platform", "function", "extension"], 27 | "buffering": { 28 | "timeoutMs": TIMEOUT_MS, 29 | "maxBytes": MAX_BYTES, 30 | "maxItems": MAX_ITEMS 31 | } 32 | }; 33 | 34 | subscription_request_headers = { 35 | "Content-Type": "application/json", 36 | LAMBDA_EXTENSION_IDENTIFIER_HEADER_KEY: extension_id, 37 | } 38 | 39 | response = requests.put( 40 | TELEMETRY_API_URL, 41 | data = json.dumps(subscription_request_body), 42 | headers= subscription_request_headers 43 | ) 44 | 45 | if response.status_code == 200: 46 | print("[telemetry_api_client.subscibe_listener] Extension successfully subscribed to telemetry api", response.text, flush=True) 47 | elif response.status_code == 202: 48 | print("[telemetry_api_client.subscibe_listener] Telemetry API not supported. Are you running the extension locally?", flush=True) 49 | else: 50 | print("[telemetry_api_client.subscibe_listener] Subsciption to telmetry API failed. ", "status code: ", response.status_code, "response text: ", response.text, flush=True) 51 | return extension_id 52 | 53 | except Exception as e: 54 | print("Error registering extension.", e, flush=True) 55 | raise Exception("Error setting AWS_LAMBDA_RUNTIME_API", e) 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/telemetry_dispatcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from telemetry_service import ( 4 | log_telemetry_stream, 5 | ) 6 | 7 | DISPATCH_MIN_BATCH_SIZE = int(os.getenv("DISPATCH_MIN_BATCH_SIZE")); 8 | 9 | 10 | def dispatch_telmetery(queue, force): 11 | while ((not queue.empty()) and (force or queue.qsize() >= DISPATCH_MIN_BATCH_SIZE)): 12 | print("[telementry_dispatcher] Dispatch telemetry data") 13 | batch = queue.get_nowait() 14 | log_telemetry_stream(batch) 15 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/test.json: -------------------------------------------------------------------------------- 1 | [{'time': '2022-09-13T15:42:46.607Z', 'type': 'platform.initStart', 'record': {'initializationType': 'on-demand'}}, {'time': '2022-09-13T15:42:46.664Z', 'type': 'extension', 'record': 'telemetry_api_extension launching extension\n'}, {'time': '2022-09-13T15:42:46.948Z', 'type': 'extension', 'record': 'Starting the Telemetry API Extension\n'}, {'time': '2022-09-13T15:42:46.948Z', 'type': 'extension', 'record': 'Extension Main: Registring the extension using extension name: telemetry_api_extension\n'}, {'time': '2022-09-13T15:42:46.952Z', 'type': 'extension', 'record': '[extension_api_client.register_extension] Registering Extension using http://127.0.0.1:9001/2020-01-01/extension\n'}, {'time': '2022-09-13T15:42:46.953Z', 'type': 'extension', 'record': '[extension_api_client.register_extension] Registration success with extensionId d99e7a61-47be-4456-84d1-d85f29a7a2e3\n'}, {'time': '2022-09-13T15:42:46.953Z', 'type': 'extension', 'record': 'Extension Main: Starting the http listener which will receive data from Telemetry API\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': '[telemetery_http_listener.start_http_listener] Starting http listener on sandbox.localdomain:4243\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': '[telemetery_http_listener.start_http_listener] Started http listener\n'}, {'time': '2022-09-13T15:42:46.964Z', 'type': 'extension', 'record': 'Extension Main: Subscribing the listener to TelemetryAPI\n'}] -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/extensions/telemetry-api/telemetry_api_extension/test_req.txt: -------------------------------------------------------------------------------- 1 | altgraph @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/altgraph-0.17.2-py2.py3-none-any.whl 2 | astroid==3.2.2 3 | boto3==1.34.131 4 | botocore==1.34.131 5 | dill==0.3.8 6 | future @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/future-0.18.2-py3-none-any.whl 7 | git-remote-codecommit==1.16 8 | isort==5.13.2 9 | jmespath==1.0.1 10 | macholib @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/macholib-1.15.2-py2.py3-none-any.whl 11 | mccabe==0.7.0 12 | platformdirs==4.2.2 13 | pylint==3.2.3 14 | python-dateutil==2.8.2 15 | s3transfer==0.10.1 16 | six @ file:///AppleInternal/Library/BuildRoots/9dd5efe2-7fad-11ee-b588-aa530c46a9ea/Library/Caches/com.apple.xbs/Sources/python3/six-1.15.0-py2.py3-none-any.whl 17 | tomli==2.0.1 18 | tomlkit==0.12.5 19 | typing_extensions==4.12.2 20 | urllib3==1.26.15 21 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/models/order_models.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | class Order: 5 | key='' 6 | def __init__(self, shardId, orderId, orderName, orderProducts): 7 | self.shardId = shardId 8 | self.orderId = orderId 9 | self.key = shardId + ':' + orderId 10 | self.orderName = orderName 11 | self.orderProducts = orderProducts 12 | 13 | class OrderProduct: 14 | 15 | def __init__(self, productId, price, quantity): 16 | self.productId = productId 17 | self.price = price 18 | self.quantity = quantity 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/models/product_models.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | class Product: 5 | key ='' 6 | def __init__(self, shardId, productId, sku, name, price, category): 7 | self.shardId = shardId 8 | self.productId = productId 9 | self.key = shardId + ':' + productId 10 | self.sku = sku 11 | self.name = name 12 | self.price = price 13 | self.category = category 14 | 15 | class Category: 16 | def __init__(self, id, name): 17 | self.id = id 18 | self.name = name 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/requirements.txt: -------------------------------------------------------------------------------- 1 | aws_lambda_powertools[Tracer,Logger,Metrics] 2 | jsonpickle 3 | aws_requests_auth 4 | requests 5 | simplejson 6 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/product-service/src/utils/__init__.py -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/utils/aggregator_util.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | from decimal import * 4 | import json 5 | 6 | 7 | def query_cloudwatch_logs(logs, log_group_name, query_string, start_time, end_time) -> dict: 8 | query = logs.start_query(logGroupName=log_group_name, 9 | startTime=start_time, 10 | endTime=end_time, 11 | queryString=query_string) 12 | 13 | query_results = logs.get_query_results(queryId=query["queryId"]) 14 | 15 | while query_results['status'] == 'Running' or query_results['status'] == 'Scheduled': 16 | # nosem 17 | time.sleep(5) 18 | query_results = logs.get_query_results(queryId=query["queryId"]) 19 | 20 | return query_results 21 | 22 | 23 | def get_start_date_time(): 24 | time_zone = datetime.now().astimezone().tzinfo 25 | start_date_time = int(datetime.now(tz=time_zone).date().strftime('%s')) # current day epoch 26 | return start_date_time 27 | 28 | 29 | def get_end_date_time(): 30 | time_zone = datetime.now().astimezone().tzinfo 31 | end_date_time = int((datetime.now(tz=time_zone) + timedelta(days=1)).date().strftime('%s')) # next day epoch 32 | return end_date_time 33 | 34 | 35 | def get_s3_key(prefix, service): 36 | # Get the current date and time. 37 | now = datetime.now() 38 | 39 | # Format strings for year, month, and current date. 40 | year = now.strftime('%Y') # Current year like '2024'. 41 | month = now.strftime('%m') # Current month like '07'. 42 | current_date = now.strftime('%m-%d-%Y') # Current date like '07-30-2024'. 43 | 44 | # Format the key with the current year, month, and date 45 | key = prefix + '/year={}/month={}/{}-usage_by_tenant-{}.json'.format(year, month, service, current_date) 46 | return key 47 | 48 | 49 | def get_line_delimited_json(data): 50 | # Initialize an empty string to hold all JSON strings. 51 | line_delimited_json = "" 52 | 53 | # Loop through each dictionary in the list, convert it to a JSON string, and append it to the string with a newline. 54 | for item in data: 55 | line_delimited_json += json.dumps(item) + "\n" 56 | 57 | return line_delimited_json 58 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/utils/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from aws_lambda_powertools import Logger 5 | logger = Logger() 6 | 7 | """Log info messages 8 | """ 9 | def info(log_message): 10 | # logger.structure_logs(append=True, tenant_id=tenant_id) 11 | logger.info(log_message) 12 | 13 | """Log error messages 14 | """ 15 | def error(log_message): 16 | # logger.structure_logs(append=True, tenant_id=tenant_id) 17 | logger.error(log_message) 18 | 19 | """Log with tenant context. Extracts tenant context from the lambda events 20 | """ 21 | def log_with_tenant_context(event, log_message): 22 | print(event) 23 | logger.structure_logs(append=True, tenant_id=event['requestContext']['authorizer']['tenantId']) 24 | logger.info(log_message) 25 | 26 | 27 | """Log with tenant context. Extracts tenant context from the lambda events 28 | """ 29 | def log_with_tenant_and_function_context(event, context, log_dict, log_message): 30 | tenant_log = { 31 | "type": "function.tenantUsage", 32 | "resource": event['resource'], 33 | "httpMethod": event['httpMethod'], 34 | "tenant_id": event['requestContext']['authorizer']['tenantId'], 35 | "tenant_tier": event['requestContext']['authorizer']['tenantTier'], 36 | "functionName": context.function_name, 37 | "functionVersion": context.function_version, 38 | "awsRequestId": context.aws_request_id, 39 | } 40 | 41 | log = tenant_log | log_dict # Merge additional log properties. 42 | logger.append_keys(**log) 43 | logger.structure_logs(append=True) 44 | logger.info(log_message) 45 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/utils/metrics_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | from aws_lambda_powertools import Metrics 6 | 7 | metrics = Metrics() 8 | 9 | 10 | def record_metric(event, metric_name, metric_unit, metric_value): 11 | """ Record the metric in Cloudwatch using EMF format 12 | 13 | Args: 14 | event ([type]): [description] 15 | metric_name ([type]): [description] 16 | metric_unit ([type]): [description] 17 | metric_value ([type]): [description] 18 | """ 19 | metrics.add_dimension(name="tenant_id", value=event['requestContext']['authorizer']['tenantId']) 20 | metrics.add_metric(name=metric_name, unit=metric_unit, value=metric_value) 21 | metrics_object = metrics.serialize_metric_set() 22 | metrics.clear_metrics() 23 | print(json.dumps(metrics_object)) 24 | 25 | -------------------------------------------------------------------------------- /saas-app-plane/product-service/src/utils/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | aws-lambda-powertools[Tracer,Logger,Metrics] 3 | jsonpickle 4 | aws_requests_auth 5 | simplejson 6 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your CDK TypeScript project 2 | 3 | This is a blank project for CDK development with TypeScript. 4 | 5 | The `cdk.json` file tells the CDK Toolkit how to execute your app. 6 | 7 | ## Useful commands 8 | 9 | * `npm run build` compile typescript to js 10 | * `npm run watch` watch for changes and compile 11 | * `npm run test` perform the jest unit tests 12 | * `npx cdk deploy` deploy this stack to your default AWS account/region 13 | * `npx cdk diff` compare deployed stack with current state 14 | * `npx cdk synth` emits the synthesized CloudFormation template 15 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/bin/shared-services.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { SharedServicesStack } from '../lib/shared-services-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | 9 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 10 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 11 | 12 | new SharedServicesStack(app, 'SharedServicesStack', { 13 | env: { 14 | account: process.env.CDK_DEFAULT_ACCOUNT, 15 | region: process.env.CDK_DEFAULT_REGION 16 | } 17 | }); -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "availability-zones:account=686790399189:region=ap-south-1": [ 3 | "ap-south-1a", 4 | "ap-south-1b", 5 | "ap-south-1c" 6 | ], 7 | "availability-zones:account=686790399189:region=ap-northeast-1": [ 8 | "ap-northeast-1a", 9 | "ap-northeast-1c", 10 | "ap-northeast-1d" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/interfaces/identity-details.ts: -------------------------------------------------------------------------------- 1 | export interface IdentityDetails { 2 | name: string; 3 | details: { 4 | [key: string]: any; 5 | }; 6 | } 7 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/lib/identity-provider.ts: -------------------------------------------------------------------------------- 1 | import { aws_cognito, StackProps, RemovalPolicy } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { IdentityDetails } from '../interfaces/identity-details'; 4 | 5 | export class IdentityProvider extends Construct { 6 | public readonly identityDetails: IdentityDetails; 7 | 8 | constructor(scope: Construct, id: string, props?: StackProps) { 9 | super(scope, id); 10 | 11 | const tenantUserPool = new aws_cognito.UserPool(this, 'tenantUserPool', { 12 | autoVerify: {email: true}, 13 | accountRecovery: aws_cognito.AccountRecovery.EMAIL_ONLY, 14 | removalPolicy: RemovalPolicy.DESTROY, 15 | advancedSecurityMode: aws_cognito.AdvancedSecurityMode.ENFORCED, 16 | passwordPolicy: { 17 | minLength: 8, 18 | requireLowercase: true, 19 | requireUppercase: true, 20 | requireSymbols: true, 21 | requireDigits: true 22 | }, 23 | standardAttributes: { 24 | email: { 25 | required: true, 26 | mutable: true, 27 | }, 28 | }, 29 | customAttributes: { 30 | tenantId: new aws_cognito.StringAttribute({ 31 | mutable: true, 32 | }), 33 | userRole: new aws_cognito.StringAttribute({ 34 | mutable: true, 35 | }), 36 | tenantTier: new aws_cognito.StringAttribute({ 37 | mutable: true, 38 | }), 39 | features: new aws_cognito.StringAttribute({ 40 | mutable: true, 41 | }) 42 | }, 43 | }); 44 | 45 | const writeAttributes = new aws_cognito.ClientAttributes() 46 | .withStandardAttributes({email: true}) 47 | .withCustomAttributes('tenantId', 'userRole', 'tenantTier','features'); 48 | 49 | const tenantUserPoolClient = new aws_cognito.UserPoolClient(this, 'tenantUserPoolClient', { 50 | userPool: tenantUserPool, 51 | generateSecret: false, 52 | authFlows: { 53 | userPassword: true, 54 | adminUserPassword: true, 55 | userSrp: true, 56 | custom: false, 57 | }, 58 | writeAttributes: writeAttributes, 59 | oAuth: { 60 | scopes: [ 61 | aws_cognito.OAuthScope.EMAIL, 62 | aws_cognito.OAuthScope.OPENID, 63 | aws_cognito.OAuthScope.PROFILE, 64 | ], 65 | flows: { 66 | authorizationCodeGrant: true, 67 | implicitCodeGrant: true, 68 | }, 69 | }, 70 | }); 71 | 72 | this.identityDetails = { 73 | name: 'Cognito', 74 | details: { 75 | userPoolId: tenantUserPool.userPoolId, 76 | appClientId: tenantUserPoolClient.userPoolClientId, 77 | }, 78 | }; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/lib/saas-tenant-provision.ts: -------------------------------------------------------------------------------- 1 | import {Stack, StackProps, Fn, Tags} from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { 4 | CoreApplicationPlane, 5 | DetailType, 6 | EventManager 7 | } from "@cdklabs/sbt-aws"; 8 | import { EventBus } from 'aws-cdk-lib/aws-events'; 9 | 10 | import { PolicyDocument } from "aws-cdk-lib/aws-iam"; 11 | import * as fs from "fs"; 12 | 13 | 14 | export class SaaSTenantProvision extends Construct { 15 | constructor(scope: Construct, id: string, props?: StackProps) { 16 | super(scope, id); 17 | 18 | const eventBusArn = Fn.importValue('ControlPlaneEventBusArn') 19 | 20 | const provisioningJobRunnerProps = { 21 | name: "provisioning", 22 | permissions: PolicyDocument.fromJson( 23 | JSON.parse(` 24 | { 25 | "Version":"2012-10-17", 26 | "Statement":[ 27 | { 28 | "Action":[ 29 | "*" 30 | ], 31 | "Resource":"*", 32 | "Effect":"Allow" 33 | } 34 | ] 35 | } 36 | `) 37 | ), 38 | script: fs.readFileSync("../../provision-tenant.sh", "utf8"), 39 | environmentJSONVariablesFromIncomingEvent: [ 40 | "tenantId", 41 | "tenantName", 42 | "email", 43 | "tenantTier", 44 | "tenantStatus", 45 | "features" 46 | ], 47 | environmentVariablesToOutgoingEvent: ["tenantStatus"], 48 | scriptEnvironmentVariables: {}, 49 | outgoingEvent: DetailType.PROVISION_SUCCESS, 50 | incomingEvent: DetailType.ONBOARDING_REQUEST, 51 | }; 52 | 53 | const eventBus = EventBus.fromEventBusArn(this, 'EventBus', eventBusArn); 54 | const eventManagerNew = new EventManager(this, 'EventManager', { 55 | eventBus: eventBus, 56 | }); 57 | 58 | new CoreApplicationPlane(this, "CoreApplicationPlane", { 59 | eventManager: eventManagerNew, 60 | jobRunnerPropsList: [provisioningJobRunnerProps], 61 | }); 62 | } 63 | } 64 | 65 | 66 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/lib/usage-aggregator.ts: -------------------------------------------------------------------------------- 1 | import { Stack, CfnOutput, Fn } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { LambdaFunction } from './lambda-function'; 4 | import * as path from "path"; 5 | import * as iam from 'aws-cdk-lib/aws-iam'; 6 | 7 | export interface UsageAggregatorProps { 8 | serverlessSaaSAPIAccessLogArn: string; 9 | serverlessSaaSAPIAccessLogName: string; 10 | tenantUsageBucketName: string; 11 | tenantUsageBucketArn: string; 12 | } 13 | 14 | export class SharedServicesUsageAggregatorStack extends Construct { 15 | constructor(scope: Construct, id: string, props: UsageAggregatorProps) { 16 | super(scope, id); 17 | 18 | const serverlessSaaSAPIAccessLogArn = props.serverlessSaaSAPIAccessLogArn 19 | const serverlessSaaSAPIAccessLogName = props.serverlessSaaSAPIAccessLogName 20 | const tenantUsageBucketArn = props.tenantUsageBucketArn; 21 | const tenantUsageBucketName = props.tenantUsageBucketName; 22 | 23 | const coarseGrainedAggregatorLambda = new LambdaFunction(this, 'CoarseGrainedAggregatorLambda', { 24 | entry: path.join(__dirname, '../../src'), 25 | handler: 'lambda_handler', 26 | index: 'coarse_grained_aggregator.py', 27 | powertoolsServiceName: 'COARSE_GRAINED_AGGREGATOR', 28 | powertoolsNamespace: 'TenantUsageAggregator', 29 | logLevel: 'DEBUG', 30 | }); 31 | 32 | coarseGrainedAggregatorLambda.lambdaFunction.addToRolePolicy( 33 | new iam.PolicyStatement({ 34 | effect: iam.Effect.ALLOW, 35 | actions: ['logs:StartQuery', 'logs:GetQueryResults'], 36 | resources: [serverlessSaaSAPIAccessLogArn], 37 | }) 38 | ); 39 | 40 | coarseGrainedAggregatorLambda.lambdaFunction.addToRolePolicy( 41 | new iam.PolicyStatement({ 42 | effect: iam.Effect.ALLOW, 43 | actions: ['s3:PutObject'], 44 | resources: [`${tenantUsageBucketArn}/*`], 45 | }) 46 | ); 47 | coarseGrainedAggregatorLambda.lambdaFunction.addEnvironment('SERVERLESS_SAAS_API_GATEWAY_ACCESS_LOGS', serverlessSaaSAPIAccessLogName); 48 | coarseGrainedAggregatorLambda.lambdaFunction.addEnvironment('TENANT_USAGE_BUCKET', tenantUsageBucketName); 49 | 50 | new CfnOutput(this, 'CoarseGrainedUsageAggregatorLambda', { 51 | value: coarseGrainedAggregatorLambda.lambdaFunction.functionName, 52 | exportName: 'CoarseGrainedUsageAggregatorLambda', 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/tenant-usr-mgmt.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "aws-cdk": "2.130.0", 17 | "jest": "^29.7.0", 18 | "ts-jest": "^29.1.2", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "@aws-cdk/aws-lambda-python-alpha": "^2.146.0-alpha.0", 24 | "@cdklabs/sbt-aws": "0.1.5", 25 | "aws-cdk-lib": "2.146.0", 26 | "cdk-nag": "2.28.195", 27 | "constructs": "^10.0.0", 28 | "source-map-support": "^0.5.21" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, [ 7 | { 8 | id: 'AwsSolutions-S1', 9 | reason: 'Disable S3 Bucket access logs. Not required for use case or compliance needs.' 10 | }, 11 | { 12 | id: 'AwsSolutions-IAM4', 13 | reason: 'AWS Managed policies are permitted.' 14 | }, 15 | { 16 | id: 'AwsSolutions-IAM5', 17 | reason: 'Allow wildcard expressions to grant permissions for multi-related actions that share a common prefix.' 18 | }, 19 | { 20 | id: 'AwsSolutions-L1', 21 | reason: 'Specify fixed lambda runtime version to ensure compatibility with application testing and deployments.' 22 | }, 23 | { 24 | id: 'AwsSolutions-APIG2', 25 | reason: 'API Gateway request validation is unnecessary; custom logic in the integration handles validation and logging of request errors.' 26 | }, 27 | { 28 | id: 'AwsSolutions-APIG4', 29 | reason: 'Custom request authorizer is being used.' 30 | }, 31 | { 32 | id: 'AwsSolutions-COG4', 33 | reason: 'Cognito user pool authorizer unnecessary; Custom request authorizer is being used.' 34 | }, 35 | ]); 36 | } 37 | } -------------------------------------------------------------------------------- /saas-app-plane/shared-services/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | [[ -z "$1" ]] && { 4 | cp ../../../Solution/saas-app-plane/shared-services/src/tenant_authorizer.py ../src 5 | } 6 | 7 | pip install pylint 8 | 9 | cd ../src 10 | 11 | cd ../cdk 12 | npm install 13 | npm run build 14 | 15 | cdk bootstrap 16 | echo "CDK Bootstrap complete" 17 | cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 18 | echo "CDK Deploy complete" 19 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/scripts/out.json: -------------------------------------------------------------------------------- 1 | null -------------------------------------------------------------------------------- /saas-app-plane/shared-services/scripts/package-app-plane.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | PWD 3 | APP_PLANE_ARCHIVE_FILENAME="../../../saas-app-plane.zip" 4 | ZIP_FILE_NAME="package/saas-app-plane.zip" 5 | 6 | if [ -f "$APP_PLANE_ARCHIVE_FILENAME" ]; then 7 | rm "$APP_PLANE_ARCHIVE_FILENAME" 8 | fi 9 | 10 | cd ../../ 11 | PWD 12 | zip -r ../saas-app-plane.zip . -x ".git/*" -x "**/node_modules/*" -x "**/cdk.out/*" 13 | 14 | echo $ZIP_FILE_NAME 15 | 16 | if [ $# -eq 1 ]; then 17 | if [ $1 == "upload" ]; then 18 | cd ./shared-services/scripts/ 19 | PWD 20 | BUCKET_NAME=$(aws cloudformation describe-stacks --stack-name SharedServicesStack --query "Stacks[0].Outputs[?ExportName=='AthenaOutputBucketName'].OutputValue" | jq -r '.[0]') 21 | aws s3 cp $APP_PLANE_ARCHIVE_FILENAME s3://$BUCKET_NAME/$ZIP_FILE_NAME 22 | fi 23 | fi -------------------------------------------------------------------------------- /saas-app-plane/shared-services/scripts/test-aggregator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | STACK_NAME_SHAREDINFRA=SharedServicesStack 4 | echo "Testing Usage Aggregator Service" 5 | 6 | COARSE_GRAINED_AGGREGATOR=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='CoarseGrainedUsageAggregatorLambda'].OutputValue" --output text) 7 | TENANT_USAGE_BUCKET=$(aws cloudformation describe-stacks --stack-name $STACK_NAME_SHAREDINFRA --query "Stacks[0].Outputs[?ExportName=='TenantUsageBucketName'].OutputValue" --output text) 8 | echo "Coarse Grained Aggregator: $COARSE_GRAINED_AGGREGATOR" 9 | echo "Tenant Usage Bucket: $TENANT_USAGE_BUCKET" 10 | 11 | aws lambda invoke --function-name $COARSE_GRAINED_AGGREGATOR out.json 12 | 13 | echo "Checking if the results were saved in S3" 14 | aws s3 ls s3://$TENANT_USAGE_BUCKET/coarse_grained/ -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/shared-services/src/__init__.py -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/abstract_classes/i_aggregator.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class IAggregator(ABC): 5 | @abstractmethod 6 | def calculate_daily_attribution_by_tenant(self): 7 | pass 8 | 9 | @abstractmethod 10 | def apportion_overall_usage_by_tenant(self, usage_by_tenant) -> list: 11 | pass 12 | 13 | @abstractmethod 14 | def aggregate_tenant_usage(self, start_date_time, end_date_time) -> dict: 15 | pass 16 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/abstract_classes/idp_authorizer_abstract_class.py: -------------------------------------------------------------------------------- 1 | import abc 2 | class IdpAuthorizerAbstractClass (abc.ABC): 3 | 4 | @abc.abstractmethod 5 | def validateJWT(self,event): 6 | pass -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/abstract_classes/idp_user_management_abstract_class.py: -------------------------------------------------------------------------------- 1 | import abc 2 | class IdpUserManagementAbstractClass (abc.ABC): 3 | 4 | @abc.abstractmethod 5 | def create_user(self, event): 6 | pass 7 | 8 | @abc.abstractmethod 9 | def get_users(self, event): 10 | pass 11 | 12 | @abc.abstractmethod 13 | def get_user(self, event): 14 | pass 15 | 16 | @abc.abstractmethod 17 | def update_user(self, event): 18 | pass 19 | 20 | @abc.abstractmethod 21 | def disable_user(self, event): 22 | pass 23 | 24 | @abc.abstractmethod 25 | def enable_user(self, event): 26 | pass 27 | 28 | @abc.abstractmethod 29 | def delete_user(self, event): 30 | pass -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/cognito/user_management_util.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | 3 | cognito = boto3.client('cognito-idp') 4 | 5 | 6 | def create_user_group(user_pool_id, group_name): 7 | response = cognito.create_group( 8 | GroupName=group_name, 9 | UserPoolId=user_pool_id, 10 | Precedence=0 11 | ) 12 | return response 13 | 14 | 15 | def create_user(user_pool_id, user_details): 16 | response = cognito.admin_create_user( 17 | Username=user_details['userName'], 18 | UserPoolId=user_pool_id, 19 | ForceAliasCreation=True, 20 | UserAttributes= 21 | [ 22 | { 23 | 'Name': 'email', 24 | 'Value': user_details['userEmail'] 25 | }, 26 | { 27 | 'Name': 'email_verified', 28 | 'Value': 'true' 29 | }, 30 | { 31 | 'Name': 'custom:userRole', 32 | 'Value': user_details['userRole'] 33 | }, 34 | { 35 | 'Name': 'custom:tenantId', 36 | 'Value': user_details['tenantId'] 37 | }, 38 | 39 | ] 40 | ) 41 | return response 42 | 43 | def add_user_to_group(user_pool_id, user_name, group_name): 44 | response = cognito.admin_add_user_to_group( 45 | UserPoolId=user_pool_id, 46 | Username=user_name, 47 | GroupName=group_name 48 | ) 49 | return response 50 | 51 | def user_group_exists(user_pool_id, group_name): 52 | try: 53 | response=cognito.get_group( 54 | UserPoolId=user_pool_id, 55 | GroupName=group_name) 56 | return True 57 | except Exception as e: 58 | return False 59 | 60 | def validate_user_tenancy(user_pool_id, user_name, group_name): 61 | isValid = False 62 | list_of_groups = cognito.admin_list_groups_for_user( 63 | UserPoolId=user_pool_id, 64 | Username=user_name 65 | ) 66 | for group in list_of_groups['Groups']: 67 | if group['GroupName'] == group_name: 68 | isValid = True 69 | break 70 | return isValid 71 | 72 | 73 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools[all] 2 | python-jose[cryptography] 3 | simplejson 4 | jsonpickle 5 | aws_requests_auth -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution/3e211fb0759c255ffb4a317097cc5baf8436f1da/saas-app-plane/shared-services/src/utils/__init__.py -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/aggregator_util.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime, timedelta 3 | from decimal import * 4 | import json 5 | 6 | 7 | def query_cloudwatch_logs(logs, log_group_name, query_string, start_time, end_time) -> dict: 8 | query = logs.start_query(logGroupName=log_group_name, 9 | startTime=start_time, 10 | endTime=end_time, 11 | queryString=query_string) 12 | 13 | query_results = logs.get_query_results(queryId=query["queryId"]) 14 | 15 | while query_results['status'] == 'Running' or query_results['status'] == 'Scheduled': 16 | # nosem 17 | time.sleep(5) 18 | query_results = logs.get_query_results(queryId=query["queryId"]) 19 | 20 | return query_results 21 | 22 | 23 | def get_start_date_time(): 24 | time_zone = datetime.now().astimezone().tzinfo 25 | start_date_time = int(datetime.now(tz=time_zone).date().strftime('%s')) # current day epoch 26 | return start_date_time 27 | 28 | 29 | def get_end_date_time(): 30 | time_zone = datetime.now().astimezone().tzinfo 31 | end_date_time = int((datetime.now(tz=time_zone) + timedelta(days=1)).date().strftime('%s')) # next day epoch 32 | return end_date_time 33 | 34 | 35 | def get_s3_key(prefix, service): 36 | # Get the current date and time. 37 | now = datetime.now() 38 | 39 | # Format strings for year, month, and current date. 40 | year = now.strftime('%Y') # Current year like '2024'. 41 | month = now.strftime('%m') # Current month like '07'. 42 | current_date = now.strftime('%m-%d-%Y') # Current date like '07-30-2024'. 43 | 44 | # Format the key with the current year, month, and date 45 | key = prefix + '/year={}/month={}/{}-usage_by_tenant-{}.json'.format(year, month, service, current_date) 46 | return key 47 | 48 | 49 | def get_line_delimited_json(data): 50 | # Initialize an empty string to hold all JSON strings. 51 | line_delimited_json = "" 52 | 53 | # Loop through each dictionary in the list, convert it to a JSON string, and append it to the string with a newline. 54 | for item in data: 55 | line_delimited_json += json.dumps(item) + "\n" 56 | 57 | return line_delimited_json 58 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/idp_object_factory.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | def get_idp_user_mgmt_object(idp_name): 4 | 5 | idp_impl_class = '' 6 | if (idp_name.upper() == 'COGNITO'): 7 | idp_impl_class = getattr(importlib.import_module("cognito.cognito_user_management_service"), "CognitoUserManagementService") 8 | 9 | return idp_impl_class() 10 | 11 | def get_idp_authorizer_object(idp_name): 12 | 13 | idp_impl_class = '' 14 | if (idp_name.upper() == 'COGNITO'): 15 | idp_impl_class = getattr(importlib.import_module("cognito.cognito_authorizer"), "CognitoAuthorizer") 16 | 17 | return idp_impl_class() 18 | 19 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/logger.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | from aws_lambda_powertools import Logger 5 | logger = Logger() 6 | 7 | """Log info messages 8 | """ 9 | def info(log_message): 10 | #logger.structure_logs(append=True, tenant_id=tenant_id) 11 | logger.info (log_message) 12 | 13 | """Log error messages 14 | """ 15 | def error(log_message): 16 | #logger.structure_logs(append=True, tenant_id=tenant_id) 17 | logger.error (log_message) 18 | 19 | """Log with tenant context. Extracts tenant context from the lambda events 20 | """ 21 | def log_with_tenant_context(event, log_message): 22 | print(event) 23 | logger.structure_logs(append=True, tenant_id= event['requestContext']['authorizer']['tenantId']) 24 | logger.info (log_message) -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/metrics_manager.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import json 5 | from aws_lambda_powertools import Metrics 6 | 7 | metrics = Metrics() 8 | 9 | 10 | def record_metric(event, metric_name, metric_unit, metric_value): 11 | """ Record the metric in Cloudwatch using EMF format 12 | 13 | Args: 14 | event ([type]): [description] 15 | metric_name ([type]): [description] 16 | metric_unit ([type]): [description] 17 | metric_value ([type]): [description] 18 | """ 19 | metrics.add_dimension(name="tenant_id", value=event['requestContext']['authorizer']['tenantId']) 20 | metrics.add_metric(name=metric_name, unit=metric_unit, value=metric_value) 21 | metrics_object = metrics.serialize_metric_set() 22 | metrics.clear_metrics() 23 | print(json.dumps(metrics_object)) 24 | 25 | -------------------------------------------------------------------------------- /saas-app-plane/shared-services/src/utils/requirements.txt: -------------------------------------------------------------------------------- 1 | aws-lambda-powertools[all] 2 | python-jose[cryptography] 3 | simplejson 4 | jsonpickle 5 | aws_requests_auth -------------------------------------------------------------------------------- /saas-control-plane/cdk/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/README.md: -------------------------------------------------------------------------------- 1 | To deploy the control plane 2 | 3 | ``` 4 | cd scripts 5 | ./deploy.sh 6 | ``` 7 | 8 | Deploy the app plane before running the below tests 9 | 10 | To test the control plane tenant management api 11 | 12 | ``` 13 | ./test.sh 14 | ``` 15 | 16 | 17 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/bin/saas-control-plane.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import { Aspects, App } from 'aws-cdk-lib'; 4 | import { SaaSControlPlaneStack } from '../lib/saas-control-plane-stack'; 5 | import { AwsSolutionsChecks } from 'cdk-nag' 6 | 7 | const app = new App(); 8 | // Add the cdk-nag AwsSolutions Pack with extra verbose logging enabled. 9 | Aspects.of(app).add(new AwsSolutionsChecks({verbose: true})); 10 | 11 | // required input parameters 12 | if (!process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL) { 13 | throw new Error("Please provide system admin email"); 14 | } 15 | 16 | if (!process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME) { 17 | process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME = "SystemAdmin"; 18 | } 19 | 20 | const controlPlaneStack = new SaaSControlPlaneStack(app, 'SaaSControlPlaneStack', { 21 | systemAdminRoleName: process.env.CDK_PARAM_SYSTEM_ADMIN_ROLE_NAME, 22 | systemAdminEmail: process.env.CDK_PARAM_SYSTEM_ADMIN_EMAIL, 23 | }); 24 | 25 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | roots: ['/test'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.tsx?$': 'ts-jest' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/lib/saas-control-plane-stack.ts: -------------------------------------------------------------------------------- 1 | import { Stack, StackProps, Tags, CfnOutput } from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import { ControlPlane, CognitoAuth } from "@cdklabs/sbt-aws"; 4 | import { CdkNagUtils } from '../utils/cdk-nag-utils' 5 | 6 | interface ControlPlaneStackProps extends StackProps { 7 | readonly systemAdminRoleName: string; 8 | readonly systemAdminEmail: string; 9 | } 10 | 11 | export class SaaSControlPlaneStack extends Stack { 12 | 13 | constructor(scope: Construct, id: string, props: ControlPlaneStackProps) { 14 | super(scope, id, props); 15 | 16 | // Handle CDK nag suppressions. 17 | CdkNagUtils.suppressCDKNag(this); 18 | 19 | Tags.of(this).add('saas-service', 'tenant-management'); 20 | 21 | const cognitoAuth = new CognitoAuth(this, "CognitoAuth", { 22 | systemAdminRoleName: props.systemAdminRoleName, 23 | systemAdminEmail: props.systemAdminEmail, 24 | }); 25 | 26 | const controlPlane = new ControlPlane(this, "ControlPlane", { 27 | auth: cognitoAuth, 28 | }); 29 | 30 | new CfnOutput(this, 'ControlPlaneEventBusArn', { 31 | value: controlPlane.eventManager.busArn, 32 | exportName: 'ControlPlaneEventBusArn', 33 | }); 34 | // ControlPlaneTenantDetailsTable is required to query tenants with features while doing test harness 35 | new CfnOutput(this, 'ControlPlaneTenantDetailsTable', { 36 | value: controlPlane.tables.tenantDetails.tableName, 37 | exportName: 'ControlPlaneTenantDetailsTable', 38 | }); 39 | 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-control-plane", 3 | "version": "0.1.0", 4 | "bin": { 5 | "saas-control-plane": "bin/saas-control-plane.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.12", 15 | "@types/node": "20.11.19", 16 | "jest": "^29.7.0", 17 | "ts-jest": "^29.1.2", 18 | "aws-cdk": "2.130.0", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "aws-cdk-lib": "2.130.0", 24 | "constructs": "^10.0.0", 25 | "cdk-nag": "2.28.195", 26 | "@cdklabs/sbt-aws": "0.1.5", 27 | "source-map-support": "^0.5.21" 28 | } 29 | } -------------------------------------------------------------------------------- /saas-control-plane/cdk/test/cdk.test.ts: -------------------------------------------------------------------------------- 1 | // import * as cdk from 'aws-cdk-lib'; 2 | // import { Template } from 'aws-cdk-lib/assertions'; 3 | // import * as Cdk from '../lib/cdk-stack'; 4 | 5 | // example test. To run these tests, uncomment this file along with the 6 | // example resource in lib/cdk-stack.ts 7 | test('SQS Queue Created', () => { 8 | // const app = new cdk.App(); 9 | // // WHEN 10 | // const stack = new Cdk.CdkStack(app, 'MyTestStack'); 11 | // // THEN 12 | // const template = Template.fromStack(stack); 13 | 14 | // template.hasResourceProperties('AWS::SQS::Queue', { 15 | // VisibilityTimeout: 300 16 | // }); 17 | }); 18 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /saas-control-plane/cdk/utils/cdk-nag-utils.ts: -------------------------------------------------------------------------------- 1 | import { NagSuppressions } from 'cdk-nag' 2 | 3 | export class CdkNagUtils { 4 | 5 | static suppressCDKNag(context: any): void { 6 | NagSuppressions.addStackSuppressions(context, []); 7 | } 8 | } -------------------------------------------------------------------------------- /saas-control-plane/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "saas-control-plane", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": {} 6 | } 7 | -------------------------------------------------------------------------------- /saas-control-plane/scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | export CDK_PARAM_SYSTEM_ADMIN_EMAIL="$1" 4 | 5 | if [[ -z "$CDK_PARAM_SYSTEM_ADMIN_EMAIL" ]]; then 6 | echo "Please provide system admin email" 7 | exit 1 8 | fi 9 | 10 | export AWS_REGION=$(aws configure get region) 11 | if [ -z "$AWS_REGION" ]; then 12 | export TOKEN=$(curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds:60") 13 | export AWS_REGION=$(curl -H "X-aws-ec2-metadata-token:${TOKEN}" -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/\(.*\)[a-z]/\1/') 14 | fi 15 | echo "REGION: ${AWS_REGION}" 16 | 17 | # Preprovision base infrastructure 18 | cd ../cdk 19 | npm install 20 | 21 | npx cdk bootstrap 22 | npx cdk deploy --all --require-approval never --concurrency 10 --asset-parallelism true 23 | -------------------------------------------------------------------------------- /ws_deployment/_manage-workshop-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | create_workshop() { 6 | get_vscodeserver_id 7 | 8 | echo "Waiting for " $VSSERVER_ID 9 | aws ec2 start-instances --instance-ids "$VSSERVER_ID" 10 | aws ec2 wait instance-status-ok --instance-ids "$VSSERVER_ID" 11 | echo $VSSERVER_ID "ready" 12 | 13 | run_ssm_command "export UV_USE_IO_URING=0 && npm install typescript" 14 | run_ssm_command "cd /${HOME_FOLDER} && git clone ${REPO_URL}" 15 | run_ssm_command "chown -R ${TARGET_USER}:${TARGET_USER} /${HOME_FOLDER}" 16 | run_ssm_command ". ~/.bashrc && cd /${HOME_FOLDER}/${REPO_NAME} && chmod +x install.sh && ./install.sh" 17 | run_ssm_command "chown -R ${TARGET_USER}:${TARGET_USER} /${HOME_FOLDER}" 18 | } 19 | -------------------------------------------------------------------------------- /ws_deployment/_workshop-conf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | ## Defines workshop configuration shared amongst scripts 6 | 7 | ## Variables 8 | REPO_URL="https://github.com/aws-samples/aws-saas-tenant-usage-and-cost-attribution" 9 | REPO_NAME="aws-saas-tenant-usage-and-cost-attribution" 10 | TARGET_USER="participant" 11 | HOME_FOLDER="Workshop" 12 | DELAY=15 # Used to sleep in functions. Tweak as desired. 13 | -------------------------------------------------------------------------------- /ws_deployment/_workshop-shared-functions.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | # Run an SSM command on an EC2 instance 6 | run_ssm_command() { 7 | SSM_COMMAND="$1" 8 | parameters=$(jq -n --arg cm "runuser -l \"$TARGET_USER\" -c \"$SSM_COMMAND\"" '{executionTimeout:["3600"], commands: [$cm]}') 9 | comment=$(echo "$SSM_COMMAND" | cut -c1-100) 10 | # send ssm command to instance id in VSSERVER_ID 11 | sh_command_id=$(aws ssm send-command \ 12 | --targets "Key=InstanceIds,Values=$VSSERVER_ID" \ 13 | --document-name "AWS-RunShellScript" \ 14 | --parameters "$parameters" \ 15 | --cloud-watch-output-config "CloudWatchOutputEnabled=true,CloudWatchLogGroupName=workshopsetuplog" \ 16 | --timeout-seconds 3600 \ 17 | --comment "$comment" \ 18 | --output text \ 19 | --query "Command.CommandId") 20 | 21 | command_status="InProgress" # seed status var 22 | while [[ "$command_status" == "InProgress" || "$command_status" == "Pending" || "$command_status" == "Delayed" ]]; do 23 | sleep $DELAY 24 | command_invocation=$(aws ssm get-command-invocation \ 25 | --command-id "$sh_command_id" \ 26 | --instance-id "$VSSERVER_ID") 27 | # echo -E "$command_invocation" | jq # for debugging purposes 28 | command_status=$(echo -E "$command_invocation" | jq -r '.Status') 29 | done 30 | 31 | if [ "$command_status" != "Success" ]; then 32 | echo "failed executing $SSM_COMMAND : $command_status" && exit 1 33 | else 34 | echo "successfully completed execution!" 35 | fi 36 | } 37 | 38 | # Get vscodeserver instance ID 39 | get_vscodeserver_id() { 40 | VSSERVER_ID=$(aws ec2 describe-instances \ 41 | --filter "Name=tag:Name,Values=VSCodeServer" \ 42 | --query 'Reservations[].Instances[].{Instance:InstanceId}' \ 43 | --output text) 44 | 45 | echo "vscodeserver instance id: $VSSERVER_ID" 46 | } 47 | -------------------------------------------------------------------------------- /ws_deployment/manage-workshop-stack.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | # SPDX-License-Identifier: MIT-0 4 | 5 | ## Import workshop configuration 6 | # This contains the create_workshop() and delete_workshop() functions 7 | FUNCTIONS=( _workshop-conf.sh _manage-workshop-stack.sh _workshop-shared-functions.sh ) 8 | for FUNCTION in "${FUNCTIONS[@]}"; do 9 | if [ -f $FUNCTION ]; then 10 | source $FUNCTION 11 | else 12 | echo "ERROR: $FUNCTION not found" 13 | fi 14 | done 15 | 16 | ## Calls the create and delete operations 17 | manage_workshop_stack() { 18 | create_workshop 19 | } 20 | 21 | for i in {1..3}; do 22 | echo "iteration number: $i" 23 | if manage_workshop_stack; then 24 | echo "successfully completed execution" 25 | exit 0 26 | else 27 | sleep "$((15*i))" 28 | fi 29 | done 30 | 31 | echo "failed to complete execution" 32 | exit 1 33 | --------------------------------------------------------------------------------