From 4fb5a6e44543b7fa19b42f1357938541d140cdd2 Mon Sep 17 00:00:00 2001 From: Biswanath Mukherjee Date: Fri, 19 Dec 2025 14:45:23 +0530 Subject: [PATCH] initial commit --- apigw-lambda-tenant-isolation/README.md | 208 +++++++++++ .../diagram/architecture.png | Bin 0 -> 30363 bytes .../example-pattern.json | 59 ++++ .../src/isolated/lambda_function.py | 143 ++++++++ .../src/standard/lambda_function.py | 132 +++++++ apigw-lambda-tenant-isolation/template.yaml | 323 ++++++++++++++++++ 6 files changed, 865 insertions(+) create mode 100644 apigw-lambda-tenant-isolation/README.md create mode 100644 apigw-lambda-tenant-isolation/diagram/architecture.png create mode 100644 apigw-lambda-tenant-isolation/example-pattern.json create mode 100644 apigw-lambda-tenant-isolation/src/isolated/lambda_function.py create mode 100644 apigw-lambda-tenant-isolation/src/standard/lambda_function.py create mode 100644 apigw-lambda-tenant-isolation/template.yaml diff --git a/apigw-lambda-tenant-isolation/README.md b/apigw-lambda-tenant-isolation/README.md new file mode 100644 index 000000000..2833b22f9 --- /dev/null +++ b/apigw-lambda-tenant-isolation/README.md @@ -0,0 +1,208 @@ +# Multi-tenant API with AWS Lambda functions tenant isolation + +This sample project demonstrates tenant isolation mode of AWS Lambda functions by comparing two Lambda functions - one with tenant isolation enabled and one without. The demonstration uses in-memory counters to visually show how tenant isolation provides separate execution environments for different tenants. + +## 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) +- [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed +- [Python 3.14 or above](https://www.python.org/downloads/) installed +- [Maven 3.8.6 or above](https://maven.apache.org/download.cgi) installed + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +2. Change directory to the pattern directory: + + ```bash + cd serverless-patterns/apigw-lambda-tenant-isolation + ``` + +3. From the command line, run the following commands: + + ```bash + sam build + sam deploy --guided + ``` + +4. During the prompts: + + - Enter a stack name + - Enter the desired AWS Region e.g. `us-east-1`. + - Allow SAM CLI to create IAM roles with the required permissions. + - Keep default values to the rest of the parameters. + + Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +5. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for next step as well as testing. + +## How it works + +The SAM template deploys two Lambda functions - one tenant isolation mode enabled and another disabled. + +![End to End Architecture](diagram/architecture.png) + +Here's a breakdown of the steps: + +1. **Standard AWS Lambda Function**: Receives tenant headers (`x-tenant-id`) but shares execution environment across all tenants. The counter variable when increased for one tenant, it impacts the other tenant (demonstrates the limitation) + +2. **Tenant-Isolated AWS Lambda Function**: Maintains separate execution environments per tenant using AWS Lambda tenant isolation mode (demonstrates the solution) + +3. **Amazon API Gateway**: Provides REST endpoints for both functions with header mapping + +## Testing + +Use [curl](https://curl.se/) to send a HTTP POST request to the API. Make sure to replace `api-id` with the one from your `sam deploy --guided` output: + +### Standard Function (The limitation) + +The standard function receives tenant headers but cannot isolate tenants - all requests share the same counter: + +Replace with `StandardMultiTenantAPIEndpointUrl`: + +```bash +STANDARD_URL="https://your-api-id.execute-api.region.amazonaws.com/dev/standard" +``` + +BlueTenant request: + +```bash +curl -H "x-tenant-id: BlueTenant" "$STANDARD_URL" +``` + +Response: + +```bash +{ + "counter": 1, + "tenant_id_received": "BlueTenant", + "tenant_id": null, + "isolation_enabled": false, + "message": "Counter incremented successfully - SHARED across all tenants! (Received tenant: BlueTenant)", + "warning": "This function does NOT provide tenant isolation - all tenants share the same counter!" +} +``` + +RedTenant request: + +```bash +curl -H "x-tenant-id: RedTenant" "$STANDARD_URL" +``` + +Response: + +```bash +{ + "counter": 2, + "tenant_id_received": "RedTenant", + "tenant_id": null, + "isolation_enabled": false, + "message": "Counter incremented successfully - SHARED across all tenants! (Received tenant: RedTenant)", + "warning": "This function does NOT provide tenant isolation - all tenants share the same counter!" +} +``` + +GreenTenant request: + +```bash +curl -H "x-tenant-id: GreenTenant" "$STANDARD_URL" +``` + +Response: + +```bash +{ + "counter": 3, + "tenant_id_received": "GreenTenant", + "tenant_id": null, + "isolation_enabled": false, + "message": "Counter incremented successfully - SHARED across all tenants! (Received tenant: GreenTenant)", + "warning": "This function does NOT provide tenant isolation - all tenants share the same counter!" +} +``` + +Continue to invoke the API for different tenants. Note the `counter` values. As all the three tenants are reusing the same Lambda execution environment, the counter variable is also shared and continuously increasing across tenants. + +### Isolated Function (The solution) + +The isolated function provides true tenant isolation - each tenant gets separate Lambda execution environments: + +Replace with `IsolatedTenantAPIEndpointUrl`: + +```bash + +ISOLATED_URL="https://your-api-id.execute-api.region.amazonaws.com/dev/isolated" +``` + +BlueTenant requests (independent counter): + +```bash +curl -H "x-tenant-id: BlueTenant" "$ISOLATED_URL" +``` + +Response: + +```bash +{ + "counter": 1, + "tenant_id": "BlueTenant", + "isolation_enabled": true, + "message": "Counter incremented successfully for tenant BlueTenant" +} +``` + +GreenTenant requests (separate independent counter): + +```bash +curl -H "x-tenant-id: GreenTenant" "$ISOLATED_URL" +``` + +Response: + +```bash +{ + "counter": 1, + "tenant_id": "GreenTenant", + "isolation_enabled": true, + "message": "Counter incremented successfully for tenant GreenTenant" +} +``` + +Continue to invoke the API for different tenants. Note the `counter` values. Each tenant maintains independent counters (BlueTenant: 1→2→3, GreenTenant: 1→2), showing true isolation. + +### Monitoring + +Check CloudWatch logs to see tenant isolation in action: + +```bash +# View logs for standard function +aws logs filter-log-events \ + --log-group-name "/aws/lambda/your-stack-name-counter-standard" \ + --start-time $(date -d '10 minutes ago' +%s)000 + +# View logs for isolated function (notice tenantId in platform events) +aws logs filter-log-events \ + --log-group-name "/aws/lambda/your-stack-name-counter-isolated" \ + --start-time $(date -d '10 minutes ago' +%s)000 +``` + +## Cleanup + +1. To delete the resources deployed to your AWS account via AWS SAM, run the following command: + +```bash +sam delete +``` + +--- + +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/apigw-lambda-tenant-isolation/diagram/architecture.png b/apigw-lambda-tenant-isolation/diagram/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..d99cf8f2af7f142b32b8559da7bfe27758e413b0 GIT binary patch literal 30363 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 literal 0 HcmV?d00001 diff --git a/apigw-lambda-tenant-isolation/example-pattern.json b/apigw-lambda-tenant-isolation/example-pattern.json new file mode 100644 index 000000000..9fde4580f --- /dev/null +++ b/apigw-lambda-tenant-isolation/example-pattern.json @@ -0,0 +1,59 @@ +{ + "title": "Multi-tenant API with AWS Lambda functions tenant isolation", + "description": "This sample project demonstrates tenant isolation mode of AWS Lambda functions.", + "language": "Python", + "level": "200", + "framework": "AWS SAM", + "introBox": { + "headline": "How it works", + "text": [ + "Amazon API Gateway receives the HTTP GET request with tenant id in the header x-tenant-id.", + "The API Gateway triggers either the standard or the tenant isolated Lambda functions depending on the URI.", + "Observe the counter variable value between standard and tenant isolation mode enabled Lambda functions are you invoke the APIs for different tenant." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-lambda-tenant-isolation", + "templateURL": "serverless-patterns/apigw-lambda-tenant-isolation", + "projectFolder": "apigw-lambda-tenant-isolation", + "templateFile": "template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda tenant isolation", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/tenant-isolation.html" + }, + { + "text": "AWS Blog - Building multi-tenant SaaS applications with AWS Lambda’s new tenant isolation mode", + "link": "https://aws.amazon.com/blogs/compute/building-multi-tenant-saas-applications-with-aws-lambdas-new-tenant-isolation-mode/" + } + ] + }, + "deploy": { + "text": [ + "sam build", + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Biswanath Mukherjee", + "image": "https://serverlessland.com/assets/images/resources/contributors/biswanath-mukherjee.jpg", + "bio": "I am a Sr. Solutions Architect working at AWS India. I help strategic global enterprise customer to architect their workload to run on AWS.", + "linkedin": "biswanathmukherjee" + } + ] +} diff --git a/apigw-lambda-tenant-isolation/src/isolated/lambda_function.py b/apigw-lambda-tenant-isolation/src/isolated/lambda_function.py new file mode 100644 index 000000000..42ecba1ff --- /dev/null +++ b/apigw-lambda-tenant-isolation/src/isolated/lambda_function.py @@ -0,0 +1,143 @@ +import json +import logging + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# In-memory counter that persists across invocations within the same execution environment +# With tenant isolation, each tenant gets their own separate instance of this counter +counter = 0 + +def lambda_handler(event, context): + """ + Tenant-isolated Lambda function handler with tenant isolation enabled. + Maintains separate counters for each tenant using context.tenant_id. + + Args: + event: API Gateway event containing request information + context: Lambda context object with tenant_id attribute + + Returns: + dict: API Gateway response with tenant-specific counter value and tenant ID + """ + global counter + + try: + # Validate event structure first + if not isinstance(event, dict): + logger.error("Invalid event structure: event is not a dictionary") + return create_error_response(400, 'Bad Request', 'Invalid request format') + + # Log the incoming request (sanitized for security) + logger.info(f"Processing isolated request - Method: {event.get('httpMethod', 'UNKNOWN')}, Path: {event.get('path', 'UNKNOWN')}") + + # Check if this is a GET request + http_method = event.get('httpMethod', '') + if not http_method: + logger.error("Missing httpMethod in event") + return create_error_response(400, 'Bad Request', 'Missing HTTP method in request') + + if http_method != 'GET': + logger.warning(f"Unsupported HTTP method: {http_method}") + return create_error_response(405, 'Method Not Allowed', f'HTTP method {http_method} is not supported. Only GET requests are allowed.') + + # Validate path (optional but good practice) + path = event.get('path', '') + if path and not path.endswith('/isolated'): + logger.warning(f"Unexpected path: {path}") + + # Get tenant ID from Lambda context + # AWS Lambda provides tenant_id in the context when tenant isolation is enabled + tenant_id = getattr(context, 'tenant_id', None) + + # Enhanced tenant ID validation + if not tenant_id: + logger.error("Missing tenant ID in Lambda context") + return create_error_response(400, 'Missing Tenant ID', 'Tenant ID is required for isolated function calls. Ensure x-tenant-id header is provided.') + + # Validate tenant ID format (basic validation) + if not isinstance(tenant_id, str) or len(tenant_id.strip()) == 0: + logger.error(f"Invalid tenant ID format: {tenant_id}") + return create_error_response(400, 'Invalid Tenant ID', 'Tenant ID must be a non-empty string') + + # Sanitize tenant ID for logging + tenant_id = tenant_id.strip() + + # Validate tenant ID length (reasonable limit) + if len(tenant_id) > 100: + logger.error(f"Tenant ID too long: {len(tenant_id)} characters") + return create_error_response(400, 'Invalid Tenant ID', 'Tenant ID must be 100 characters or less') + + # With tenant isolation, each tenant gets their own execution environment + # So this simple counter is automatically isolated per tenant by AWS Lambda + counter += 1 + + # Log the isolated behavior + logger.info(f"Tenant '{tenant_id}' using isolated counter value: {counter}") + + # Prepare response body + response_body = { + 'counter': counter, + 'tenant_id': tenant_id, + 'isolation_enabled': True, + 'message': f'Counter incremented successfully for tenant {tenant_id}' + } + + # Log the response + logger.info(f"Returning isolated counter value {counter} for tenant {tenant_id}") + + # Return successful response + return { + 'statusCode': 200, + 'headers': { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }, + 'body': json.dumps(response_body) + } + + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {str(e)}") + return create_error_response(400, 'Bad Request', 'Invalid JSON in request') + except KeyError as e: + logger.error(f"Missing required field: {str(e)}") + return create_error_response(400, 'Bad Request', f'Missing required field: {str(e)}') + except ValueError as e: + logger.error(f"Value error: {str(e)}") + return create_error_response(400, 'Bad Request', f'Invalid value: {str(e)}') + except AttributeError as e: + logger.error(f"Context attribute error: {str(e)}") + return create_error_response(500, 'Configuration Error', 'Lambda function configuration error') + except Exception as e: + # Log the error with more context + logger.error(f"Unexpected error processing isolated request: {str(e)}", exc_info=True) + + # Return generic error response for security + return create_error_response(500, 'Internal Server Error', 'An unexpected error occurred while processing the request') + + +def create_error_response(status_code, error_type, message): + """ + Create a standardized error response. + + Args: + status_code (int): HTTP status code + error_type (str): Type of error + message (str): Error message + + Returns: + dict: Standardized error response + """ + return { + 'statusCode': status_code, + 'headers': { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }, + 'body': json.dumps({ + 'error': error_type, + 'message': message, + 'statusCode': status_code + }) + } \ No newline at end of file diff --git a/apigw-lambda-tenant-isolation/src/standard/lambda_function.py b/apigw-lambda-tenant-isolation/src/standard/lambda_function.py new file mode 100644 index 000000000..73a7f66d0 --- /dev/null +++ b/apigw-lambda-tenant-isolation/src/standard/lambda_function.py @@ -0,0 +1,132 @@ +import json +import logging + +# Configure logging +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# In-memory counter that persists across invocations within the same execution environment +counter = 0 + +def lambda_handler(event, context): + """ + Standard Lambda function handler without tenant isolation. + Maintains a shared counter across all invocations within the same execution environment. + This function receives tenant headers but CANNOT isolate tenants - demonstrating the problem. + + Args: + event: API Gateway event containing request information + context: Lambda context object + + Returns: + dict: API Gateway response with counter value and isolation status + """ + global counter + + try: + # Validate event structure first + if not isinstance(event, dict): + logger.error("Invalid event structure: event is not a dictionary") + return create_error_response(400, 'Bad Request', 'Invalid request format') + + # Extract tenant ID from headers (for demonstration purposes) + headers = event.get('headers', {}) or {} + # API Gateway may pass headers in different cases, so check both + tenant_id_from_header = ( + headers.get('x-tenant-id') or + headers.get('X-Tenant-Id') or + headers.get('X-TENANT-ID') + ) + + # Log the incoming request with tenant information + logger.info(f"Processing standard request - Method: {event.get('httpMethod', 'UNKNOWN')}, Path: {event.get('path', 'UNKNOWN')}, Tenant Header: {tenant_id_from_header}") + + # Check if this is a GET request + http_method = event.get('httpMethod', '') + if not http_method: + logger.error("Missing httpMethod in event") + return create_error_response(400, 'Bad Request', 'Missing HTTP method in request') + + if http_method != 'GET': + logger.warning(f"Unsupported HTTP method: {http_method}") + return create_error_response(405, 'Method Not Allowed', f'HTTP method {http_method} is not supported. Only GET requests are allowed.') + + # Validate path (optional but good practice) + path = event.get('path', '') + if path and not path.endswith('/standard'): + logger.warning(f"Unexpected path: {path}") + + # CRITICAL DEMONSTRATION: Without tenant isolation, all tenants share the same counter! + # This is the problem that tenant isolation solves + counter += 1 + + # Log the problematic behavior + if tenant_id_from_header: + logger.warning(f"PROBLEM: Tenant '{tenant_id_from_header}' is using shared counter value {counter}! This demonstrates data leakage between tenants.") + else: + logger.info(f"Request without tenant header using shared counter value: {counter}") + + # Prepare response body - showing the received tenant header but no isolation + response_body = { + 'counter': counter, + 'tenant_id_received': tenant_id_from_header, # Show what tenant was requested + 'tenant_id': None, # But no actual tenant isolation + 'isolation_enabled': False, + 'message': f'Counter incremented successfully - SHARED across all tenants! (Received tenant: {tenant_id_from_header or "none"})', + 'warning': 'This function does NOT provide tenant isolation - all tenants share the same counter!' + } + + # Log the response with warning + logger.info(f"Returning SHARED counter value {counter} for requested tenant '{tenant_id_from_header}' - NO ISOLATION!") + + # Return successful response + return { + 'statusCode': 200, + 'headers': { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }, + 'body': json.dumps(response_body) + } + + except json.JSONDecodeError as e: + logger.error(f"JSON decode error: {str(e)}") + return create_error_response(400, 'Bad Request', 'Invalid JSON in request') + except KeyError as e: + logger.error(f"Missing required field: {str(e)}") + return create_error_response(400, 'Bad Request', f'Missing required field: {str(e)}') + except ValueError as e: + logger.error(f"Value error: {str(e)}") + return create_error_response(400, 'Bad Request', f'Invalid value: {str(e)}') + except Exception as e: + # Log the error with more context + logger.error(f"Unexpected error processing request: {str(e)}", exc_info=True) + + # Return generic error response for security + return create_error_response(500, 'Internal Server Error', 'An unexpected error occurred while processing the request') + + +def create_error_response(status_code, error_type, message): + """ + Create a standardized error response. + + Args: + status_code (int): HTTP status code + error_type (str): Type of error + message (str): Error message + + Returns: + dict: Standardized error response + """ + return { + 'statusCode': status_code, + 'headers': { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache' + }, + 'body': json.dumps({ + 'error': error_type, + 'message': message, + 'statusCode': status_code + }) + } \ No newline at end of file diff --git a/apigw-lambda-tenant-isolation/template.yaml b/apigw-lambda-tenant-isolation/template.yaml new file mode 100644 index 000000000..f59f15e2f --- /dev/null +++ b/apigw-lambda-tenant-isolation/template.yaml @@ -0,0 +1,323 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: > + Lambda Tenant Isolation Demo + + Demonstration project showcasing AWS Lambda's tenant isolation feature + with two Lambda functions - one with tenant isolation enabled and one without. + +# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst +Globals: + Function: + Timeout: 30 + MemorySize: 128 + Runtime: python3.14 + +Parameters: + Environment: + Type: String + Default: dev + Description: Environment name for resource naming + AllowedValues: + - dev + - staging + - prod + +Resources: + # IAM Execution Role for Lambda Functions + LambdaExecutionRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${AWS::StackName}-lambda-execution-role" + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + Action: + - sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: CloudWatchLogsPolicy + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - logs:CreateLogGroup + - logs:CreateLogStream + - logs:PutLogEvents + Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/*" + + # Standard Lambda Function (without tenant isolation) + CounterStandardFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-counter-standard" + CodeUri: src/standard/ + Handler: lambda_function.lambda_handler + Role: !GetAtt LambdaExecutionRole.Arn + Description: Lambda function without tenant isolation for counter demonstration + Environment: + Variables: + LOG_LEVEL: INFO + Events: + Api: + Type: Api + Properties: + RestApiId: !Ref ApiGateway + Path: /standard + Method: get + + # CloudWatch Log Group for Standard Function + CounterStandardLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-counter-standard" + RetentionInDays: 14 + + # Tenant-Isolated Lambda Function (with tenant isolation enabled) + CounterIsolatedFunction: + Type: AWS::Serverless::Function + Properties: + FunctionName: !Sub "${AWS::StackName}-counter-isolated" + CodeUri: src/isolated/ + Handler: lambda_function.lambda_handler + Role: !GetAtt LambdaExecutionRole.Arn + Description: Lambda function with tenant isolation enabled for counter demonstration + TenancyConfig: + TenantIsolationMode: PER_TENANT + Environment: + Variables: + LOG_LEVEL: INFO + + # CloudWatch Log Group for Isolated Function + CounterIsolatedLogGroup: + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub "/aws/lambda/${AWS::StackName}-counter-isolated" + RetentionInDays: 14 + + # API Gateway REST API + ApiGateway: + Type: AWS::ApiGateway::RestApi + Properties: + Name: !Sub "${AWS::StackName}-api" + Description: API Gateway for Lambda tenant isolation demonstration + EndpointConfiguration: + Types: + - REGIONAL + SecurityPolicy: SecurityPolicy_TLS13_1_3_2025_09 + EndpointAccessMode: BASIC + + # Request Validator for API Gateway + RequestValidator: + Type: AWS::ApiGateway::RequestValidator + Properties: + RestApiId: !Ref ApiGateway + Name: !Sub "${AWS::StackName}-request-validator" + ValidateRequestParameters: true + ValidateRequestBody: false + + # Error Model for API Gateway responses + ErrorModel: + Type: AWS::ApiGateway::Model + Properties: + RestApiId: !Ref ApiGateway + Name: ErrorModel + ContentType: application/json + Schema: + $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 + StandardResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref ApiGateway + ParentId: !GetAtt ApiGateway.RootResourceId + PathPart: standard + + # Standard endpoint method (with optional header mapping for demonstration) + StandardMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ApiGateway + ResourceId: !Ref StandardResource + HttpMethod: GET + AuthorizationType: NONE + RequestParameters: + method.request.header.x-tenant-id: false # Optional parameter + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${CounterStandardFunction.Arn}/invocations" + RequestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.header.x-tenant-id + MethodResponses: + - StatusCode: 200 + ResponseModels: + application/json: Empty + - StatusCode: 405 + ResponseModels: + application/json: !Ref ErrorModel + - StatusCode: 500 + ResponseModels: + application/json: !Ref ErrorModel + + # OPTIONS method for Standard endpoint (CORS support) + StandardOptionsMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ApiGateway + ResourceId: !Ref StandardResource + HttpMethod: OPTIONS + AuthorizationType: NONE + Integration: + Type: MOCK + IntegrationResponses: + - StatusCode: 200 + ResponseParameters: + 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: "'*'" + ResponseTemplates: + application/json: '' + PassthroughBehavior: WHEN_NO_MATCH + RequestTemplates: + application/json: '{"statusCode": 200}' + MethodResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Headers: false + method.response.header.Access-Control-Allow-Methods: false + method.response.header.Access-Control-Allow-Origin: false + + # Isolated endpoint resource + IsolatedResource: + Type: AWS::ApiGateway::Resource + Properties: + RestApiId: !Ref ApiGateway + ParentId: !GetAtt ApiGateway.RootResourceId + PathPart: isolated + + # Isolated endpoint method with header mapping + IsolatedMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ApiGateway + ResourceId: !Ref IsolatedResource + HttpMethod: GET + AuthorizationType: NONE + RequestParameters: + method.request.header.x-tenant-id: true # Required parameter + RequestValidatorId: !Ref RequestValidator + Integration: + Type: AWS_PROXY + IntegrationHttpMethod: POST + Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${CounterIsolatedFunction.Arn}/invocations" + RequestParameters: + integration.request.header.X-Amz-Tenant-Id: method.request.header.x-tenant-id + MethodResponses: + - StatusCode: 200 + ResponseModels: + application/json: Empty + - StatusCode: 400 + ResponseModels: + application/json: !Ref ErrorModel + - StatusCode: 405 + ResponseModels: + application/json: !Ref ErrorModel + - StatusCode: 500 + ResponseModels: + application/json: !Ref ErrorModel + + # OPTIONS method for Isolated endpoint (CORS support) + IsolatedOptionsMethod: + Type: AWS::ApiGateway::Method + Properties: + RestApiId: !Ref ApiGateway + ResourceId: !Ref IsolatedResource + HttpMethod: OPTIONS + AuthorizationType: NONE + Integration: + Type: MOCK + IntegrationResponses: + - StatusCode: 200 + ResponseParameters: + 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: "'*'" + ResponseTemplates: + application/json: '' + PassthroughBehavior: WHEN_NO_MATCH + RequestTemplates: + application/json: '{"statusCode": 200}' + MethodResponses: + - StatusCode: 200 + ResponseParameters: + method.response.header.Access-Control-Allow-Headers: false + method.response.header.Access-Control-Allow-Methods: false + method.response.header.Access-Control-Allow-Origin: false + + # Lambda permissions for API Gateway to invoke functions + StandardFunctionPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref CounterStandardFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/*" + + IsolatedFunctionPermission: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref CounterIsolatedFunction + Action: lambda:InvokeFunction + Principal: apigateway.amazonaws.com + SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGateway}/*/*" + + # API Gateway Deployment + ApiGatewayDeployment: + Type: AWS::ApiGateway::Deployment + DependsOn: + - StandardMethod + - StandardOptionsMethod + - IsolatedMethod + - IsolatedOptionsMethod + Properties: + RestApiId: !Ref ApiGateway + + # API Gateway Stage + ApiGatewayStage: + Type: AWS::ApiGateway::Stage + Properties: + RestApiId: !Ref ApiGateway + DeploymentId: !Ref ApiGatewayDeployment + StageName: !Ref Environment + Description: !Sub "Stage for ${Environment} environment" + +Outputs: + # API Gateway endpoint URLs + StandardMultiTenantAPIEndpointUrl: + Description: "URL for the standard Lambda function endpoint" + Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${ApiGatewayStage}/standard" + Export: + Name: !Sub "${AWS::StackName}-standard-url" + + IsolatedTenantAPIEndpointUrl: + Description: "URL for the tenant-isolated Lambda function endpoint" + Value: !Sub "https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com/${ApiGatewayStage}/isolated" + Export: + Name: !Sub "${AWS::StackName}-isolated-url"