diff --git a/lambda-ddb-tenant-isolation/README.md b/lambda-ddb-tenant-isolation/README.md new file mode 100644 index 0000000000..86a5eea637 --- /dev/null +++ b/lambda-ddb-tenant-isolation/README.md @@ -0,0 +1,127 @@ +# Multi-tenant API with Amazon API Gateway and AWS Lambda Tenant Isolation + +![architecture](architecture/architecture.png) + +This pattern implements a serverless multi-tenant counter API using Amazon API Gateway, AWS Lambda and Amazon DynamoDB. It deploys two parallel endpoints, one without tenant isolation and one with full per-tenant isolation, to demonstrate how shared state leads to cross-tenant data leakage in multi-tenant applications. + +When a request hits the standard endpoint, the Lambda function increments a single shared counter in DynamoDB, exposing activity across all tenants. The isolated endpoint requires a tenant ID header, which maps to a dedicated Lambda execution environment and a tenant-specific DynamoDB row. Each tenant gets an independent counter, ensuring complete data separation. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/lambda-ddb-tenant-isolation + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [Terraform](https://learn.hashicorp.cxom/tutorials/terraform/install-cli?in=terraform/aws-get-started) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +1. Change directory to the pattern directory: + ``` + cd lambda-ddb-tenant-isolation + ``` +1. From the command line, initialize terraform to downloads and installs the providers defined in the configuration: + ``` + terraform init + ``` +1. From the command line, apply the configuration in the main.tf file: + ``` + terraform apply -auto-approve + ``` +1. During the prompts + #var.aws_region + - Enter a value: {enter the region for deployment} + + #var.prefix + - Enter a value: {enter any prefix to associate with resources} + +1. Note the outputs from the Terraform deployment process. These contain the resource names and/or ARNs which are used for testing. + +## Testing + +Use [curl](https://curl.se/) to send a HTTP GET request to the API. + +1. Make a GET request to the Standard API endpoint using the following cURL command: +``` +curl -H "x-tenant-id: TENANT_ID" "STANDARD_API_ENDPOINT" +``` +Note: Replace the `TENANT_ID` with a unique Tenant ID of your choice and `STANDARD_API_ENDPOINT` with the generated `standard_multi_tenant_api_endpoint_url` from Terraform (refer to the Terraform Outputs section) + +For ex, +``` +curl -H "x-tenant-id: Tenant-1" "https://1234abcde.execute-api.us-east-1.amazonaws.com/dev/standard" +``` + +The response would be, +``` +{"counter": 1, "tenant_id": "Tenant-1", "isolation_enabled": false, "message": "This function does NOT provide tenant isolation and every tenant reads and writes the same DynamoDB row. Incremented counter is shared across all the tenants"} +``` + +Test this for another Tenant ID. For ex, +``` +curl -H "x-tenant-id: Tenant-2" "https://1234abcde.execute-api.us-east-1.amazonaws.com/dev/standard" +``` + +The response would be, +``` +{"counter": 2, "tenant_id": "Tenant-2", "isolation_enabled": false, "message": "This function does NOT provide tenant isolation and every tenant reads and writes the same DynamoDB row. Incremented counter is shared across all the tenants"} +``` + +1. Now make a GET request to the Isolated API endpoint using the following cURL command: +``` +curl -H "x-tenant-id: TENANT_ID" "ISOLATED_API_ENDPOINT" +``` +Note: Replace the `TENANT_ID` with a unique Tenant ID of your choice and `ISOLATED_API_ENDPOINT` with the generated `isolated_tenant_api_endpoint_url` from Terraform (refer to the Terraform Outputs section) + +For ex, +``` +curl -H "x-tenant-id: Tenant-1" "https://1234abcde.execute-api.us-east-1.amazonaws.com/dev/isolated" +``` + +The response would be, +``` +{"counter": 1, "tenant_id": "Tenant-1", "isolation_enabled": true, "message": "Counter incremented for tenant Tenant-1"} +``` + +Test this for another Tenant ID. For ex, +``` +curl -H "x-tenant-id: Tenant-2" "https://1234abcde.execute-api.us-east-1.amazonaws.com/dev/isolated" +``` + +The response would be, +``` +{"counter": 2, "tenant_id": "Tenant-2", "isolation_enabled": true, "message": "Counter incremented for tenant Tenant-2"} +``` + +## Cleanup + +1. Change directory to the pattern directory: + ``` + cd serverless-patterns/lambda-ddb-tenant-isolation + ``` + +1. Delete all created resources + ``` + terraform destroy -auto-approve + ``` + +1. During the prompts: + ``` + Enter all details as entered during creation. + ``` + +1. Confirm all created resources has been deleted + ``` + terraform show + ``` +---- +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 \ No newline at end of file diff --git a/lambda-ddb-tenant-isolation/architecture/architecture.png b/lambda-ddb-tenant-isolation/architecture/architecture.png new file mode 100644 index 0000000000..227756cacf Binary files /dev/null and b/lambda-ddb-tenant-isolation/architecture/architecture.png differ diff --git a/lambda-ddb-tenant-isolation/example-pattern.json b/lambda-ddb-tenant-isolation/example-pattern.json new file mode 100644 index 0000000000..7a4df9d74a --- /dev/null +++ b/lambda-ddb-tenant-isolation/example-pattern.json @@ -0,0 +1,55 @@ +{ + "title": "Multi-tenant API with Amazon API Gateway and AWS Lambda Tenant Isolation", + "description": "This pattern implements a serverless multi-tenant API using Amazon API Gateway, AWS Lambda and Amazon DynamoDB to demonstrate tenant isolation.", + "language": "Python", + "level": "200", + "framework": "Terraform", + "introBox": { + "headline": "How it works", + "text": [ + "This solution works by exposing two API Gateway endpoints, /standard and /isolated, each backed by a separate Lambda function. When a request hits the /standard endpoint, the Lambda function increments a single shared counter row in DynamoDB, meaning all tenants read and write the same value. When a request hits the /isolated endpoint with an x-tenant-id header, API Gateway maps the header to the Lambda execution context, ensuring a dedicated execution environment per tenant, and the Lambda function increments a tenant-specific counter row in DynamoDB, keeping each tenant's data completely separate." + + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/lambda-ddb-tenant-isolation", + "templateURL": "serverless-patterns/lambda-ddb-tenant-isolation", + "projectFolder": "lambda-ddb-tenant-isolation", + "templateFile": "main.tf" + } + }, + "resources": { + "bullets": [ + { + "text": "Lambda Tenant Isolation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html" + } + ] + }, + "deploy": { + "text": [ + "terraform init" + "terraform apply" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "terraform destroy" + "terraform show" + ] + }, + "authors": [ + { + "name": "Archana V", + "image": "https://media.licdn.com/dms/image/v2/D5603AQGhkVtEhllFEw/profile-displayphoto-shrink_200_200/B56ZZH3LL6H0AY-/0/1744962369852?e=1772668800&v=beta&t=y0t7bsQKJwy5McO395hDmW7QPu_K-a1WKXeTA0-ecno", + "bio": "Solutions Architect at AWS", + "linkedin": "archanavenkat" + } + ] +} diff --git a/lambda-ddb-tenant-isolation/isolated_lambda_function.zip b/lambda-ddb-tenant-isolation/isolated_lambda_function.zip new file mode 100644 index 0000000000..a8967f95b2 Binary files /dev/null and b/lambda-ddb-tenant-isolation/isolated_lambda_function.zip differ diff --git a/lambda-ddb-tenant-isolation/main.tf b/lambda-ddb-tenant-isolation/main.tf new file mode 100644 index 0000000000..ed81dcb009 --- /dev/null +++ b/lambda-ddb-tenant-isolation/main.tf @@ -0,0 +1,681 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.31.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +# ────────────────────────────────────────────── +# Variables — Terraform will prompt for prefix and region +# ────────────────────────────────────────────── + +variable "aws_region" { + description = "AWS region for resources (e.g. us-east-1, us-west-2)" + type = string + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.aws_region)) + error_message = "Must be a valid AWS region (e.g. us-east-1, eu-west-2)." + } +} + +variable "prefix" { + description = "Unique prefix for all resource names — avoids collisions (e.g. your initials or team name)" + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9\\-]{1,20}$", var.prefix)) + error_message = "Prefix must be 2-21 lowercase alphanumeric characters or hyphens, starting with a letter or number." + } +} + +variable "environment" { + description = "Environment name for resource naming" + type = string + default = "dev" + validation { + condition = contains(["dev", "staging", "prod"], var.environment) + error_message = "Environment must be dev, staging, or prod." + } +} + +# ────────────────────────────────────────────── +# Locals — single place that builds the name prefix +# ────────────────────────────────────────────── + +locals { + name_prefix = "${var.prefix}-tenant-iso" +} + +# Data sources +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +# ────────────────────────────────────────────── +# DynamoDB Tables +# ────────────────────────────────────────────── + +resource "aws_dynamodb_table" "shared_counter_table" { + name = "${local.name_prefix}-shared-counters" + billing_mode = "PAY_PER_REQUEST" + hash_key = "pk" + + attribute { + name = "pk" + type = "S" + } + + tags = { + Environment = var.environment + Prefix = var.prefix + Purpose = "Shared counter - demonstrates lack of tenant isolation" + } +} + +resource "aws_dynamodb_table" "isolated_counter_table" { + name = "${local.name_prefix}-isolated-counters" + billing_mode = "PAY_PER_REQUEST" + hash_key = "tenant_id" + + attribute { + name = "tenant_id" + type = "S" + } + + tags = { + Environment = var.environment + Prefix = var.prefix + Purpose = "Per-tenant counters with tenant isolation" + } +} + +# ────────────────────────────────────────────── +# IAM +# ────────────────────────────────────────────── + +resource "aws_iam_role" "lambda_execution_role" { + name = "${local.name_prefix}-lambda-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Service = "lambda.amazonaws.com" + } + Action = "sts:AssumeRole" + } + ] + }) + + tags = { + Environment = var.environment + Prefix = var.prefix + } +} + +resource "aws_iam_role_policy_attachment" "lambda_basic_execution" { + role = aws_iam_role.lambda_execution_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "cloudwatch_logs_policy" { + name = "${local.name_prefix}-cw-logs" + role = aws_iam_role.lambda_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ] + Resource = "arn:aws:logs:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:log-group:/aws/lambda/${local.name_prefix}-*" + } + ] + }) +} + +resource "aws_iam_role_policy" "dynamodb_policy" { + name = "${local.name_prefix}-dynamodb" + role = aws_iam_role.lambda_execution_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "AllowSharedTable" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem" + ] + Resource = aws_dynamodb_table.shared_counter_table.arn + }, + { + Sid = "AllowIsolatedTable" + Effect = "Allow" + Action = [ + "dynamodb:GetItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem" + ] + Resource = aws_dynamodb_table.isolated_counter_table.arn + } + ] + }) +} + +# ────────────────────────────────────────────── +# CloudWatch Log Groups +# ────────────────────────────────────────────── + +resource "aws_cloudwatch_log_group" "counter_standard_log_group" { + name = "/aws/lambda/${local.name_prefix}-counter-standard" + retention_in_days = 14 + + tags = { + Environment = var.environment + Prefix = var.prefix + } +} + +resource "aws_cloudwatch_log_group" "counter_isolated_log_group" { + name = "/aws/lambda/${local.name_prefix}-counter-isolated" + retention_in_days = 14 + + tags = { + Environment = var.environment + Prefix = var.prefix + } +} + +# ────────────────────────────────────────────── +# Lambda Functions +# ────────────────────────────────────────────── + +resource "aws_lambda_function" "counter_standard_function" { + filename = "standard_lambda_function.zip" + function_name = "${local.name_prefix}-counter-standard" + role = aws_iam_role.lambda_execution_role.arn + handler = "standard_lambda_function.lambda_handler" + runtime = "python3.14" + timeout = 30 + memory_size = 128 + description = "[${var.prefix}] Lambda without tenant isolation — shared DynamoDB counter" + + environment { + variables = { + LOG_LEVEL = "INFO" + TABLE_NAME = aws_dynamodb_table.shared_counter_table.name + } + } + + tags = { + Environment = var.environment + Prefix = var.prefix + } + + depends_on = [ + aws_cloudwatch_log_group.counter_standard_log_group, + aws_iam_role_policy_attachment.lambda_basic_execution, + aws_iam_role_policy.dynamodb_policy + ] +} + +resource "aws_lambda_function" "counter_isolated_function" { + filename = "isolated_lambda_function.zip" + function_name = "${local.name_prefix}-counter-isolated" + role = aws_iam_role.lambda_execution_role.arn + handler = "isolated_lambda_function.lambda_handler" + runtime = "python3.14" + timeout = 30 + memory_size = 128 + description = "[${var.prefix}] Lambda with tenant isolation — per-tenant DynamoDB counter" + + tenancy_config { + tenant_isolation_mode = "PER_TENANT" + } + + environment { + variables = { + LOG_LEVEL = "INFO" + TABLE_NAME = aws_dynamodb_table.isolated_counter_table.name + } + } + + tags = { + Environment = var.environment + Prefix = var.prefix + } + + depends_on = [ + aws_cloudwatch_log_group.counter_isolated_log_group, + aws_iam_role_policy_attachment.lambda_basic_execution, + aws_iam_role_policy.dynamodb_policy + ] +} + +# ────────────────────────────────────────────── +# API Gateway REST API +# ────────────────────────────────────────────── + +resource "aws_api_gateway_rest_api" "api_gateway" { + name = "${local.name_prefix}-api" + description = "[${var.prefix}] API Gateway for Lambda tenant isolation demonstration" + + endpoint_configuration { + types = ["REGIONAL"] + } + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = "*" + Action = "execute-api:Invoke" + Resource = "*" + } + ] + }) + + tags = { + Environment = var.environment + Prefix = var.prefix + } +} + +# Request Validator +resource "aws_api_gateway_request_validator" "request_validator" { + name = "${local.name_prefix}-request-validator" + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + validate_request_parameters = true + validate_request_body = false +} + +# Error Model +resource "aws_api_gateway_model" "error_model" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + name = "ErrorModel" + content_type = "application/json" + + schema = jsonencode({ + "$schema" = "http://json-schema.org/draft-04/schema#" + title = "Error Schema" + type = "object" + properties = { + error = { + type = "string" + } + message = { + type = "string" + } + statusCode = { + type = "integer" + } + } + }) +} + +# ──────────────── Standard endpoint ──────────────── + +resource "aws_api_gateway_resource" "standard_resource" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + parent_id = aws_api_gateway_rest_api.api_gateway.root_resource_id + path_part = "standard" +} + +resource "aws_api_gateway_method" "standard_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = "GET" + authorization = "NONE" + + request_parameters = { + "method.request.header.tenant-id" = false + } +} + +resource "aws_api_gateway_integration" "standard_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_method.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.counter_standard_function.invoke_arn + + request_parameters = { + "integration.request.header.X-Amz-Tenant-Id" = "method.request.header.tenant-id" + } +} + +resource "aws_api_gateway_method_response" "standard_response_200" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_method.http_method + status_code = "200" + + response_models = { + "application/json" = "Empty" + } +} + +resource "aws_api_gateway_method_response" "standard_response_405" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_method.http_method + status_code = "405" + + response_models = { + "application/json" = aws_api_gateway_model.error_model.name + } +} + +resource "aws_api_gateway_method_response" "standard_response_500" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_method.http_method + status_code = "500" + + response_models = { + "application/json" = aws_api_gateway_model.error_model.name + } +} + +# Standard OPTIONS (CORS) +resource "aws_api_gateway_method" "standard_options_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = "OPTIONS" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "standard_options_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_options_method.http_method + type = "MOCK" + + request_templates = { + "application/json" = "{\"statusCode\": 200}" + } +} + +resource "aws_api_gateway_method_response" "standard_options_response_200" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_options_method.http_method + status_code = "200" + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = true + "method.response.header.Access-Control-Allow-Methods" = true + "method.response.header.Access-Control-Allow-Origin" = true + } +} + +resource "aws_api_gateway_integration_response" "standard_options_integration_response" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.standard_resource.id + http_method = aws_api_gateway_method.standard_options_method.http_method + status_code = aws_api_gateway_method_response.standard_options_response_200.status_code + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token'" + "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS'" + "method.response.header.Access-Control-Allow-Origin" = "'*'" + } + + response_templates = { + "application/json" = "" + } +} + +# ──────────────── Isolated endpoint ──────────────── + +resource "aws_api_gateway_resource" "isolated_resource" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + parent_id = aws_api_gateway_rest_api.api_gateway.root_resource_id + path_part = "isolated" +} + +resource "aws_api_gateway_method" "isolated_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = "GET" + authorization = "NONE" + request_validator_id = aws_api_gateway_request_validator.request_validator.id + + request_parameters = { + "method.request.header.x-tenant-id" = true + } +} + +resource "aws_api_gateway_integration" "isolated_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_method.http_method + integration_http_method = "POST" + type = "AWS_PROXY" + uri = aws_lambda_function.counter_isolated_function.invoke_arn + + request_parameters = { + "integration.request.header.X-Amz-Tenant-Id" = "method.request.header.x-tenant-id" + } +} + +resource "aws_api_gateway_method_response" "isolated_response_200" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_method.http_method + status_code = "200" + + response_models = { + "application/json" = "Empty" + } +} + +resource "aws_api_gateway_method_response" "isolated_response_400" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_method.http_method + status_code = "400" + + response_models = { + "application/json" = aws_api_gateway_model.error_model.name + } +} + +resource "aws_api_gateway_method_response" "isolated_response_405" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_method.http_method + status_code = "405" + + response_models = { + "application/json" = aws_api_gateway_model.error_model.name + } +} + +resource "aws_api_gateway_method_response" "isolated_response_500" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_method.http_method + status_code = "500" + + response_models = { + "application/json" = aws_api_gateway_model.error_model.name + } +} + +# Isolated OPTIONS (CORS) +resource "aws_api_gateway_method" "isolated_options_method" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = "OPTIONS" + authorization = "NONE" +} + +resource "aws_api_gateway_integration" "isolated_options_integration" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_options_method.http_method + type = "MOCK" + + request_templates = { + "application/json" = "{\"statusCode\": 200}" + } +} + +resource "aws_api_gateway_method_response" "isolated_options_response_200" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_options_method.http_method + status_code = "200" + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = true + "method.response.header.Access-Control-Allow-Methods" = true + "method.response.header.Access-Control-Allow-Origin" = true + } +} + +resource "aws_api_gateway_integration_response" "isolated_options_integration_response" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + resource_id = aws_api_gateway_resource.isolated_resource.id + http_method = aws_api_gateway_method.isolated_options_method.http_method + status_code = aws_api_gateway_method_response.isolated_options_response_200.status_code + + response_parameters = { + "method.response.header.Access-Control-Allow-Headers" = "'Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token,x-tenant-id'" + "method.response.header.Access-Control-Allow-Methods" = "'GET,OPTIONS'" + "method.response.header.Access-Control-Allow-Origin" = "'*'" + } + + response_templates = { + "application/json" = "" + } +} + +# ────────────────────────────────────────────── +# Lambda Permissions for API Gateway +# ────────────────────────────────────────────── + +resource "aws_lambda_permission" "standard_function_permission" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.counter_standard_function.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.api_gateway.execution_arn}/*/*" +} + +resource "aws_lambda_permission" "isolated_function_permission" { + statement_id = "AllowAPIGatewayInvoke" + action = "lambda:InvokeFunction" + function_name = aws_lambda_function.counter_isolated_function.function_name + principal = "apigateway.amazonaws.com" + source_arn = "${aws_api_gateway_rest_api.api_gateway.execution_arn}/*/*" +} + +# ────────────────────────────────────────────── +# API Gateway Deployment & Stage +# ────────────────────────────────────────────── + +resource "aws_api_gateway_deployment" "api_gateway_deployment" { + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + + triggers = { + redeployment = sha1(jsonencode([ + aws_api_gateway_resource.standard_resource.id, + aws_api_gateway_method.standard_method.id, + aws_api_gateway_integration.standard_integration.id, + aws_api_gateway_resource.isolated_resource.id, + aws_api_gateway_method.isolated_method.id, + aws_api_gateway_integration.isolated_integration.id, + ])) + } + + lifecycle { + create_before_destroy = true + } + + depends_on = [ + aws_api_gateway_method.standard_method, + aws_api_gateway_integration.standard_integration, + aws_api_gateway_method.standard_options_method, + aws_api_gateway_integration.standard_options_integration, + aws_api_gateway_method.isolated_method, + aws_api_gateway_integration.isolated_integration, + aws_api_gateway_method.isolated_options_method, + aws_api_gateway_integration.isolated_options_integration, + ] +} + +resource "aws_api_gateway_stage" "api_gateway_stage" { + deployment_id = aws_api_gateway_deployment.api_gateway_deployment.id + rest_api_id = aws_api_gateway_rest_api.api_gateway.id + stage_name = var.environment + description = "[${var.prefix}] Stage for ${var.environment} environment" + + tags = { + Environment = var.environment + Prefix = var.prefix + } +} + +# ────────────────────────────────────────────── +# Outputs +# ────────────────────────────────────────────── + +output "prefix_used" { + description = "The prefix used for all resources" + value = var.prefix +} + +output "standard_lambda_function_name" { + description = "Standard Lambda function name" + value = aws_lambda_function.counter_standard_function.function_name +} + +output "isolated_lambda_function_name" { + description = "Isolated Lambda function name" + value = aws_lambda_function.counter_isolated_function.function_name +} + +output "dyanamodb_shared_counter_table_name" { + description = "DynamoDB table for shared counters" + value = aws_dynamodb_table.shared_counter_table.name +} + +output "dynamodb_isolated_counter_table_name" { + description = "DynamoDB table for isolated per-tenant counters" + value = aws_dynamodb_table.isolated_counter_table.name +} + +output "api_gateway_id" { + description = "API Gateway REST API ID" + value = aws_api_gateway_rest_api.api_gateway.id +} + +output "standard_multi_tenant_api_endpoint_url" { + description = "URL for the standard Lambda function endpoint" + value = "${aws_api_gateway_stage.api_gateway_stage.invoke_url}/standard" +} + +output "isolated_tenant_api_endpoint_url" { + description = "URL for the tenant-isolated Lambda function endpoint" + value = "${aws_api_gateway_stage.api_gateway_stage.invoke_url}/isolated" +} diff --git a/lambda-ddb-tenant-isolation/standard_lambda_function.zip b/lambda-ddb-tenant-isolation/standard_lambda_function.zip new file mode 100644 index 0000000000..7685c585b3 Binary files /dev/null and b/lambda-ddb-tenant-isolation/standard_lambda_function.zip differ