Deploy a Tenant-Isolated API on AWS

Deploy a JWT-protected equipment API with PostgreSQL tenant isolation on AWS.

Introduction

30 Second Summary

A mining operator may share one equipment system with several organisations. Each organisation needs confidence that its records stay invisible to every other customer.

In this project, you will deploy an authenticated equipment API to AWS in Africa (Cape Town). PostgreSQL Row-Level Security will use each signed-in user's tenant identity to block cross-tenant records.

What You'll Build

You'll see your live HTTPS endpoint return only the signed-in tenant's equipment.

By the end of this project, you'll have:

  • A repeatable AWS SAM deployment that creates a live equipment API protected by Amazon Cognito. You can redeploy or delete the complete stack from infrastructure as code.
  • A tenant A identity that retrieves Haul Truck A1 from a shared Amazon Aurora PostgreSQL table. A tenant B identity that retrieves Drill Rig B1 from that same table.
  • A database-enforced isolation proof that returns an empty result when tenant A deliberately requests tenant B's path.
  • Secret Mission: Watch Aurora auto-pause at 0 ACUs. Wake it with a real API request.

Are there any prerequisites?

You'll need an AWS account with permission to create the services used in this project. You'll also need Visual Studio Code on Windows.

The setup step covers every missing command-line tool before you create cloud resources.

Before We Start

An application tenant filter can be changed or missed, so the shared equipment registry needs a database-enforced backstop. This checkpoint commits you to testing tenant-a against tenant-b with PostgreSQL Row-Level Security protecting the rows.

Set Up the Windows Toolchain

Your later deployment runs from Windows PowerShell and creates resources in AWS. A missing command or expired session would stop that process before the API exists.

This step prepares AWS CLI 2.38.1 or later. You will also install Python 3.14.8 plus AWS SAM CLI 1.169.0. Your temporary AWS session will target af-south-1.

In this step, get ready to:
  • Prepare AWS CLI with an authenticated session for Africa (Cape Town).
  • Install Python 3.14.8.
  • Install AWS SAM CLI 1.169.0.
Prepare AWS CLI for Cape Town

AWS CLI lets PowerShell create cloud resources through commands. You need version 2.38.1 or later because the sign-in flow requires a recent AWS CLI release.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search box.
  • Press Enter to open Windows PowerShell.
  • Check your installed AWS CLI version by running this command:
aws --version

What does this command check?

This command prints the installed AWS CLI version. Use that result to choose the matching tab below.

✔️ I see version 2.38.1 or later

Your AWS CLI is ready. That clears the first tool check for the deployment.

ⓧ I see an older version

Your current AWS CLI needs an update before you use the temporary sign-in flow.

  • Update AWS CLI for your Windows user by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex

What does this command do?

This PowerShell command downloads the official AWS installer script. The script installs AWS CLI for your current Windows user.

  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify the updated AWS CLI version by running this command:
aws --version

What should I see?

You should see AWS CLI version 2.38.1 or later. The new PowerShell window loads the updated command.

Still seeing the older version?

Close every PowerShell window before checking again. An existing window can keep using the previous command path.

If the installation does not complete, confirm that your internet connection allows downloads from the AWS installer address.

Help me troubleshoot my AWS CLI installation.

ⓧ AWS CLI is unavailable

AWS CLI still needs to be installed for your Windows user.

  • Install AWS CLI for your Windows user by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex

What does this command do?

This PowerShell command downloads the official AWS installer script. The script installs AWS CLI for your current Windows user.

  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify the AWS CLI installation by running this command:
aws --version

What should I see?

You should see AWS CLI version 2.38.1 or later. That output confirms PowerShell can find the installed command.

AWS CLI still unavailable?

Make sure you opened a new PowerShell window after the installation completed.

If the installation stops early, confirm that your Windows user can install software.

Help me troubleshoot my AWS CLI installation.

Signing in gives AWS CLI temporary credentials through your browser. This keeps long-lived access keys out of your PowerShell history.

  • Start the temporary AWS sign-in by running this command:
aws login

What happens during sign-in?

AWS CLI starts a browser flow for your AWS account. It stores temporary credentials for the command-line session.

  • Complete the browser sign-in with your AWS account.
  • Return to Windows PowerShell.
  • Choose af-south-1 when AWS CLI asks for a Region.
  • Verify the temporary AWS session by running this command:
aws sts get-caller-identity

What should I see?

You should see your authenticated AWS identity details. This proves PowerShell can make authenticated AWS requests in your current session.

Identity check unsuccessful?

Complete the browser flow before returning to PowerShell. An unfinished browser sign-in leaves the CLI without temporary credentials.

If the session has expired, repeat the AWS sign-in command from earlier.

Help me troubleshoot my temporary AWS session.

Install Python 3.14.8

The Lambda functions in this project use the Python 3.14 runtime. Installing Python 3.14.8 locally gives AWS SAM the matching language toolchain.

  • Check for Python 3.14 by running this command:
py -V:3.14 --version

What does this command check?

The Windows Python selector targets the installed 3.14 runtime. The command prints its exact maintenance version.

✔️ I see Python 3.14.8

Python is ready. Your local runtime now matches the version used by this project.

ⓧ I see another version

The project needs Python 3.14.8 for a consistent local build.

  • Download the official Python 3.14.8 64-bit Windows installer.
  • Run the downloaded installer from your Downloads folder.
  • Complete the installation with the default options.
  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify Python 3.14.8 by running this command:
py -V:3.14 --version

What should I see?

You should see Python 3.14.8 in the output. This confirms the Windows Python selector can find the required runtime.

Python version still unexpected?

Confirm that you ran the 3.14.8 installer. Another installed Python version can remain on your computer without replacing this runtime.

Open a new PowerShell window before checking again.

Help me troubleshoot my Python installation.

ⓧ The Python check fails

Python 3.14.8 still needs to be installed on Windows.

  • Download the official Python 3.14.8 64-bit Windows installer.
  • Run the downloaded installer from your Downloads folder.
  • Complete the installation with the default options.
  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify the Python installation by running this command:
py -V:3.14 --version

What should I see?

You should see Python 3.14.8 in the output. PowerShell can now select the required runtime.

Python check still failing?

Make sure the installer completed before you reopened PowerShell.

Run the downloaded 64-bit installer again if the first installation stopped early.

Help me troubleshoot my Python installation.

Install AWS SAM CLI 1.169.0

AWS SAM CLI reads the infrastructure template in the next step. It builds the Lambda code before AWS CloudFormation deploys the application.

  • Check your installed AWS SAM CLI version by running this command:
sam --version

What does this command check?

This command prints the installed AWS SAM CLI version. Use the result to choose the matching path below.

✔️ I see version 1.169.0

AWS SAM CLI is ready. You now have the build tool required for the serverless application.

ⓧ I see an older version

Install AWS SAM CLI 1.169.0 so the project uses the verified release.

  • Download AWS_SAM_CLI_64_PY3.msi from the official AWS SAM CLI release page.
  • Run AWS_SAM_CLI_64_PY3.msi from your Downloads folder.
  • Complete the installer with the default options.
  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify the updated AWS SAM CLI version by running this command:
sam --version

What should I see?

The output should include version 1.169.0. This confirms the updated command is available in your new PowerShell window.

Still seeing the older SAM version?

Close every PowerShell window before checking again. An existing window can keep the previous command path.

Confirm that you ran the installer named AWS_SAM_CLI_64_PY3.msi.

Help me troubleshoot my AWS SAM CLI installation.

ⓧ AWS SAM CLI is unavailable

AWS SAM CLI still needs to be installed before you can build the application.

  • Download AWS_SAM_CLI_64_PY3.msi from the official AWS SAM CLI release page.
  • Run AWS_SAM_CLI_64_PY3.msi from your Downloads folder.
  • Complete the installer with the default options.
  • Close the current PowerShell window.
  • Return to Windows PowerShell through Windows search.
  • Verify the AWS SAM CLI installation by running this command:
sam --version

What should I see?

The output should include version 1.169.0. PowerShell can now find the AWS SAM CLI command.

AWS SAM CLI still unavailable?

Make sure the installer completed before you opened the new PowerShell window.

Run AWS_SAM_CLI_64_PY3.msi again if the first installation stopped early.

Help me troubleshoot my AWS SAM CLI installation.

Before you run the final check, do you expect all three tools plus your AWS identity to answer in the same PowerShell window?

  • Verify the complete Windows toolchain by running these commands:
aws --version
py -V:3.14 --version
sam --version
aws sts get-caller-identity

What should I see?

  • The AWS CLI output shows version 2.38.1 or later.
  • The Python output shows 3.14.8.
  • The AWS SAM CLI output shows 1.169.0.
  • The AWS identity command prints your authenticated identity details.

One final check unsuccessful?

Open a new PowerShell window if an installed command still shows an old version or remains unavailable.

Repeat the AWS sign-in flow if only the identity check is unsuccessful.

Help me diagnose my toolchain check.

That is the foundation in place. Your tools can build the serverless application while your temporary AWS session can deploy it to Africa (Cape Town).

Your Windows toolchain is ready. Next up, you will build the application and deploy its protected HTTPS endpoint.

Deploy the Protected API

Your Windows toolchain is ready. You can now turn the architecture into an AWS SAM application that AWS CloudFormation can deploy as one stack.

This first cloud version exposes a route protected by a JWT authorizer. You will call the route without signing in to prove that an anonymous request cannot reach the equipment data.

In this step, get ready to:
  • Create the complete SAM application files.
  • Build and deploy the stack in af-south-1.
  • Test the protected endpoint without an identity token.
Create and build the SAM project

Infrastructure as code stores the complete cloud architecture in files that you can rebuild. The template defines the resources while the Python files define the two Lambda functions.

  • Create the `tenant-equipment-api` folder and its four project files by running these commands in PowerShell:
Set-Location -Path "$HOME\Desktop"
New-Item -Path 'tenant-equipment-api' -ItemType Directory
Set-Location -Path 'tenant-equipment-api'
New-Item -Path 'template.yaml' -ItemType File
New-Item -Path 'src' -ItemType Directory
New-Item -Path 'src\requirements.txt', 'src\bootstrap.py', 'src\app.py' -ItemType File

What do these commands create?

  • The `tenant-equipment-api` directory keeps the local SAM project on your Desktop.
  • The `src` directory holds the Lambda source code and its dependency pin.
  • The empty files provide exact destinations for the complete code below.
  • Press the Windows key to open Windows search.
  • Type `Visual Studio Code`.
  • Press Enter to start Visual Studio Code.
  • Select **File** from the top menu.
  • Select **Open Folder...**.
  • Choose the `tenant-equipment-api` folder on your Desktop.
  • Confirm that the **Explorer** sidebar lists `template.yaml` and the three files inside `src`.

Project files missing?

Check that PowerShell was inside your Desktop before it created `tenant-equipment-api`. Expand the `src` directory in the Visual Studio Code **Explorer** to reveal its three files.

Need another pair of eyes? Help me find the project files created by PowerShell.

Why Lambda and API Gateway?

AWS Lambda runs the API code only when a request arrives. Amazon API Gateway provides the HTTPS endpoint and checks authentication before invoking that code.

This serverless approach keeps the project focused on tenant identity and database isolation. A container migration can build on the same security pattern later.

  • Select the second tab below to copy each complete file into its matching location.

✔️ Awesome, I've got everything!

Great. Double-check that all four files are saved inside `tenant-equipment-api`.

ⓧ I'd like to double check the full code

Replace the contents of `template.yaml` with this complete SAM template:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Tenant-isolated equipment API with Cognito, Lambda, Aurora PostgreSQL, Data API, and RLS

Parameters:
  EngineVersion:
    Type: String
    Description: An af-south-1 Aurora PostgreSQL 16.x version at or above 16.3

Resources:
  EquipmentVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.20.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: tenant-equipment-vpc

  DatabaseSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref EquipmentVpc
      CidrBlock: 10.20.1.0/24
      AvailabilityZone: !Select [0, !GetAZs '']

  DatabaseSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref EquipmentVpc
      CidrBlock: 10.20.2.0/24
      AvailabilityZone: !Select [1, !GetAZs '']

  DatabaseSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: No direct database ingress; access uses RDS Data API
      VpcId: !Ref EquipmentVpc

  DatabaseSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: Private subnets for TenantEquipmentDB
      SubnetIds:
        - !Ref DatabaseSubnetA
        - !Ref DatabaseSubnetB

  TenantEquipmentDB:
    Type: AWS::RDS::DBCluster
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      DBClusterIdentifier: !Sub tenant-equipment-db-${AWS::AccountId}
      DatabaseName: equipmentdb
      Engine: aurora-postgresql
      EngineMode: provisioned
      EngineVersion: !Ref EngineVersion
      MasterUsername: dbadmin
      ManageMasterUserPassword: true
      EnableHttpEndpoint: true
      StorageEncrypted: true
      BackupRetentionPeriod: 1
      DeleteAutomatedBackups: true
      DeletionProtection: false
      DBSubnetGroupName: !Ref DatabaseSubnetGroup
      VpcSecurityGroupIds:
        - !Ref DatabaseSecurityGroup
      ServerlessV2ScalingConfiguration:
        MinCapacity: 0
        MaxCapacity: 1
        SecondsUntilAutoPause: 300

  TenantEquipmentDBInstance:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Properties:
      DBClusterIdentifier: !Ref TenantEquipmentDB
      DBInstanceClass: db.serverless
      Engine: aurora-postgresql
      PubliclyAccessible: false

  EquipmentUserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: tenant-equipment-users
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      UsernameConfiguration:
        CaseSensitive: false
      MfaConfiguration: OFF
      Policies:
        PasswordPolicy:
          MinimumLength: 12
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: true
          RequireUppercase: true
          TemporaryPasswordValidityDays: 1
      Schema:
        - Name: tenant_id
          AttributeDataType: String
          Mutable: false
          Required: false
          StringAttributeConstraints:
            MinLength: '1'
            MaxLength: '64'

  EquipmentUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId: !Ref EquipmentUserPool
      ClientName: tenant-equipment-cli
      GenerateSecret: false
      ExplicitAuthFlows:
        - ALLOW_USER_PASSWORD_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      PreventUserExistenceErrors: ENABLED
      ReadAttributes:
        - custom:tenant_id

  BootstrapFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: BootstrapDatabase
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - rds-data:ExecuteStatement
                Resource: !GetAtt TenantEquipmentDB.DBClusterArn
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: !GetAtt TenantEquipmentDB.MasterUserSecret.SecretArn

  ApiFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - !Sub arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: TenantScopedDataApi
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - rds-data:BeginTransaction
                  - rds-data:ExecuteStatement
                  - rds-data:CommitTransaction
                  - rds-data:RollbackTransaction
                Resource: !GetAtt TenantEquipmentDB.DBClusterArn
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource: !GetAtt TenantEquipmentDB.MasterUserSecret.SecretArn

  BootstrapFunction:
    Type: AWS::Serverless::Function
    DependsOn: TenantEquipmentDBInstance
    Properties:
      CodeUri: src/
      Handler: bootstrap.handler
      Runtime: python3.14
      Timeout: 120
      MemorySize: 256
      Role: !GetAtt BootstrapFunctionRole.Arn
      Environment:
        Variables:
          CLUSTER_ARN: !GetAtt TenantEquipmentDB.DBClusterArn
          SECRET_ARN: !GetAtt TenantEquipmentDB.MasterUserSecret.SecretArn
          DATABASE_NAME: equipmentdb

  ApiFunction:
    Type: AWS::Serverless::Function
    DependsOn: TenantEquipmentDBInstance
    Properties:
      CodeUri: src/
      Handler: app.handler
      Runtime: python3.14
      Timeout: 60
      MemorySize: 256
      Role: !GetAtt ApiFunctionRole.Arn
      Environment:
        Variables:
          CLUSTER_ARN: !GetAtt TenantEquipmentDB.DBClusterArn
          SECRET_ARN: !GetAtt TenantEquipmentDB.MasterUserSecret.SecretArn
          DATABASE_NAME: equipmentdb

  EquipmentApi:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: tenant-equipment-api
      ProtocolType: HTTP
      Target: !GetAtt ApiFunction.Arn

  EquipmentJwtAuthorizer:
    Type: AWS::ApiGatewayV2::Authorizer
    Properties:
      ApiId: !Ref EquipmentApi
      AuthorizerType: JWT
      IdentitySource:
        - '$request.header.Authorization'
      JwtConfiguration:
        Audience:
          - !Ref EquipmentUserPoolClient
        Issuer: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${EquipmentUserPool}
      Name: equipment-cognito-jwt

  EquipmentApiOverrides:
    Type: AWS::ApiGatewayV2::ApiGatewayManagedOverrides
    Properties:
      ApiId: !Ref EquipmentApi
      Route:
        AuthorizationType: JWT
        AuthorizerId: !Ref EquipmentJwtAuthorizer

  ApiInvokePermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref ApiFunction
      Action: lambda:InvokeFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${EquipmentApi}/$default/$default

Outputs:
  ApiUrl:
    Value: !Sub https://${EquipmentApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}
  BootstrapFunctionName:
    Value: !Ref BootstrapFunction
  UserPoolId:
    Value: !Ref EquipmentUserPool
  UserPoolClientId:
    Value: !Ref EquipmentUserPoolClient
  DBInstanceIdentifier:
    Value: !Ref TenantEquipmentDBInstance
  SelectedEngineVersion:
    Value: !Ref EngineVersion

What does the template define?

The template creates the private database network and an Amazon Aurora PostgreSQL Serverless v2 cluster. It also creates the authentication resources and the protected HTTP API.

Its outputs expose the values that later PowerShell commands need. One deployment now reproduces the entire cloud architecture.

Replace the contents of `src/requirements.txt` with this dependency pin:

boto3==1.43.114

Why pin Boto3?

The version pin makes the Lambda build use Boto3 `1.43.114`. This keeps the RDS Data API client behaviour consistent with the project code.

Replace the contents of `src/bootstrap.py` with this complete bootstrap function:

import json
import os
import time

import boto3

CLUSTER_ARN = os.environ["CLUSTER_ARN"]
SECRET_ARN = os.environ["SECRET_ARN"]
DATABASE_NAME = os.environ["DATABASE_NAME"]

rds_data = boto3.client("rds-data")

STATEMENTS = [
    """
    CREATE TABLE IF NOT EXISTS public.equipment (
        equipment_id text PRIMARY KEY,
        tenant_id text NOT NULL,
        name text NOT NULL
    )
    """,
    """
    DO $$
    BEGIN
        IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'tenant_app') THEN
            CREATE ROLE tenant_app NOLOGIN;
        END IF;
    END
    $$
    """,
    "GRANT tenant_app TO dbadmin",
    "GRANT USAGE ON SCHEMA public TO tenant_app",
    "GRANT SELECT ON public.equipment TO tenant_app",
    "ALTER TABLE public.equipment ENABLE ROW LEVEL SECURITY",
    "ALTER TABLE public.equipment FORCE ROW LEVEL SECURITY",
    """
    DO $$
    BEGIN
        IF NOT EXISTS (
            SELECT 1
            FROM pg_policies
            WHERE schemaname = 'public'
              AND tablename = 'equipment'
              AND policyname = 'tenant_isolation'
        ) THEN
            CREATE POLICY tenant_isolation ON public.equipment
            USING (tenant_id = current_setting('app.current_tenant', true))
            WITH CHECK (tenant_id = current_setting('app.current_tenant', true));
        END IF;
    END
    $$
    """,
    """
    INSERT INTO public.equipment (equipment_id, tenant_id, name)
    VALUES
        ('eq-a-1', 'tenant-a', 'Haul Truck A1'),
        ('eq-b-1', 'tenant-b', 'Drill Rig B1')
    ON CONFLICT (equipment_id) DO NOTHING
    """,
]


def execute_with_resume_retry(sql):
    for attempt in range(3):
        try:
            return rds_data.execute_statement(
                resourceArn=CLUSTER_ARN,
                secretArn=SECRET_ARN,
                database=DATABASE_NAME,
                sql=sql,
                continueAfterTimeout=True,
            )
        except rds_data.exceptions.DatabaseResumingException:
            if attempt == 2:
                raise
            time.sleep(5)


def handler(event, context):
    for statement in STATEMENTS:
        execute_with_resume_retry(statement.strip())

    return {
        "statusCode": 200,
        "body": json.dumps({"message": "Database bootstrap complete"}),
    }

What does the bootstrap function prepare?

The function uses the RDS Data API to create the shared equipment table and sample rows. It also prepares the database role and PostgreSQL Row-Level Security policy used in the next step.

The retry helper allows the operation to continue after an auto-paused database begins resuming. Repeated invocations remain safe because the SQL checks existing objects.

Replace the contents of `src/app.py` with this complete API function:

import json
import os
import time

import boto3

CLUSTER_ARN = os.environ["CLUSTER_ARN"]
SECRET_ARN = os.environ["SECRET_ARN"]
DATABASE_NAME = os.environ["DATABASE_NAME"]

rds_data = boto3.client("rds-data")


def api_response(status_code, payload):
    return {
        "statusCode": status_code,
        "headers": {"content-type": "application/json"},
        "body": json.dumps(payload),
    }


def begin_transaction_with_resume_retry():
    for attempt in range(3):
        try:
            return rds_data.begin_transaction(
                resourceArn=CLUSTER_ARN,
                secretArn=SECRET_ARN,
                database=DATABASE_NAME,
            )
        except rds_data.exceptions.DatabaseResumingException:
            if attempt == 2:
                raise
            time.sleep(5)


def handler(event, context):
    claims = (
        event.get("requestContext", {})
        .get("authorizer", {})
        .get("jwt", {})
        .get("claims", {})
    )
    tenant_id = claims.get("custom:tenant_id")

    if not tenant_id:
        return api_response(403, {"message": "Tenant claim is missing"})

    path_parts = [part for part in event.get("rawPath", "").split("/") if part]
    if len(path_parts) != 2 or path_parts[0] != "equipment":
        return api_response(404, {"message": "Use /equipment/{tenantId}"})

    requested_tenant = path_parts[1]
    transaction_id = None

    try:
        transaction = begin_transaction_with_resume_retry()
        transaction_id = transaction["transactionId"]

        rds_data.execute_statement(
            resourceArn=CLUSTER_ARN,
            secretArn=SECRET_ARN,
            database=DATABASE_NAME,
            transactionId=transaction_id,
            sql="SET LOCAL ROLE tenant_app",
        )

        rds_data.execute_statement(
            resourceArn=CLUSTER_ARN,
            secretArn=SECRET_ARN,
            database=DATABASE_NAME,
            transactionId=transaction_id,
            sql="SELECT set_config('app.current_tenant', :tenant_id, true)",
            parameters=[
                {"name": "tenant_id", "value": {"stringValue": tenant_id}}
            ],
        )

        result = rds_data.execute_statement(
            resourceArn=CLUSTER_ARN,
            secretArn=SECRET_ARN,
            database=DATABASE_NAME,
            transactionId=transaction_id,
            sql="""
                SELECT equipment_id, tenant_id, name
                FROM public.equipment
                WHERE tenant_id = :requested_tenant
                ORDER BY equipment_id
            """,
            parameters=[
                {
                    "name": "requested_tenant",
                    "value": {"stringValue": requested_tenant},
                }
            ],
            formatRecordsAs="JSON",
        )

        rds_data.commit_transaction(
            resourceArn=CLUSTER_ARN,
            secretArn=SECRET_ARN,
            transactionId=transaction_id,
        )
        transaction_id = None

        records = json.loads(result.get("formattedRecords", "[]"))
        print(
            json.dumps(
                {
                    "request_id": context.aws_request_id,
                    "tenant_id": tenant_id,
                    "requested_tenant": requested_tenant,
                    "returned": len(records),
                }
            )
        )
        return api_response(200, records)

    except Exception as error:
        if transaction_id:
            try:
                rds_data.rollback_transaction(
                    resourceArn=CLUSTER_ARN,
                    secretArn=SECRET_ARN,
                    transactionId=transaction_id,
                )
            except Exception:
                pass

        print(
            json.dumps(
                {
                    "request_id": context.aws_request_id,
                    "error_type": type(error).__name__,
                }
            )
        )
        return api_response(500, {"message": "Request failed"})

How does the API enforce tenant context?

The handler reads `custom:tenant_id` from the validated JWT claims. It carries that value into one explicit database transaction.

The transaction switches to `tenant_app` before setting `app.current_tenant`. PostgreSQL can then decide which rows the authenticated tenant may see.

  • Save all four files in Visual Studio Code.
  • Build the SAM application by running this command in the existing PowerShell window:
sam build

What does the build do?

The SAM CLI reads `template.yaml` and prepares both Lambda functions for deployment. It also installs the dependency from `src/requirements.txt` into the build artifacts.

You should see `Build Succeeded` in PowerShell. Your local infrastructure and Lambda code are now packaged together.

Build not succeeding?

Confirm that PowerShell is inside the `tenant-equipment-api` directory. Check that `template.yaml` is beside the `src` directory.

If the build reports a YAML or Python problem, compare the affected file with the complete version above. Help me diagnose my SAM build failure.

Select an engine version and deploy the stack

Amazon Aurora PostgreSQL Serverless v2 engine availability varies by Region. This project queries live availability before selecting a `16.x` version at or above `16.3`.

  • List the Aurora PostgreSQL engine versions available in Africa (Cape Town) by running this command:
aws rds describe-db-engine-versions --engine aurora-postgresql --query "*[].[EngineVersion]" --output text --region af-south-1

Why query the engine version?

Aurora engine versions can differ between Regions. Auto-pause at zero capacity requires an Aurora PostgreSQL `16.x` version at or above `16.3`.

Using the live result keeps the deployment compatible with Africa (Cape Town). The selected value flows into the template through `EngineVersion`.

  • Choose one available `16.x` version that is at least `16.3`.
  • Record that version here: your available Aurora PostgreSQL 16.x version.

This deployment creates AWS resources that can incur small temporary charges. The cleanup section deletes the project stack after your testing is complete.

Expect the deployment to take several minutes while CloudFormation creates the network and database. A quiet terminal during resource creation does not mean the process has stalled.

  • Enter `tenant-equipment-api` when the guided deployment asks for a stack name.
  • Enter `af-south-1` when it asks for a Region.
  • Enter your available Aurora PostgreSQL 16.x version for `EngineVersion`.
  • Allow the SAM CLI to create the required IAM roles.
  • Keep change-set confirmation enabled.
  • Accept the defaults for any remaining configuration prompts.
  • Start the guided deployment by running this command:
sam deploy --guided

What happens during deployment?

The SAM CLI uploads the build artifacts and asks CloudFormation to create the stack. CloudFormation tracks each resource as part of `tenant-equipment-api`.

The final output table contains the API URL and resource identifiers used by the remaining tests. Those outputs connect your local PowerShell session to the deployed stack.

That is the largest wait complete. Your protected API and its supporting AWS resources are now deployed from one repeatable template.

Deployment failed?

Read the first failed CloudFormation resource in the deployment output. Confirm that the chosen engine version appeared in the live Region query.

A permissions failure means the authenticated AWS identity cannot create one of the resources in the template. Help me interpret my SAM deployment failure.

The output table is easy to lose in a long deployment log. Capture its five values now so the next commands can use them directly.

  • Keep the deployment output visible in PowerShell.
  • Capture the five stack outputs by running these prompts:
$ApiUrl = Read-Host -Prompt "Enter ApiUrl"
$BootstrapFunctionName = Read-Host -Prompt "Enter BootstrapFunctionName"
$UserPoolId = Read-Host -Prompt "Enter UserPoolId"
$UserPoolClientId = Read-Host -Prompt "Enter UserPoolClientId"
$DBInstanceIdentifier = Read-Host -Prompt "Enter DBInstanceIdentifier"

Why store the outputs?

Each prompt stores one CloudFormation output in a PowerShell variable. Later commands can reuse those exact resource identifiers without repeated copying.

These values are resource addresses and identifiers. They are separate from passwords or tokens.

  • Display the stored values by running these variable names:
$ApiUrl
$BootstrapFunctionName
$UserPoolId
$UserPoolClientId
$DBInstanceIdentifier

What should these values show?

PowerShell should print five non-empty values. The first is an HTTPS URL while the remaining lines identify the deployed function, user pool, app client, and database instance.

Seeing a blank value?

Run the matching `Read-Host` assignment again. Paste only the value from the CloudFormation output table.

Make sure `$ApiUrl` starts with HTTPS before testing the endpoint. Help me correct my PowerShell output variables.

Test the anonymous request barrier

The endpoint exists now, but the request has no authenticated identity. This test checks whether the authorizer stops the request before the Lambda function can read equipment.

Before you run this request, do you think the anonymous caller will reach the API function?

  • Call the tenant A route without an Authorization header by running this command:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-a" -Method Get

What happened?

PowerShell reports `401 Unauthorized`. The Amazon Cognito JWT authorizer rejected the anonymous request before it reached the equipment handler.

This failure is intentional. It proves the deployed HTTPS endpoint exists and requires authentication.

Seeing a different result?

Confirm that `$ApiUrl` contains the exact `ApiUrl` stack output. Check that the request path ends with `/equipment/tenant-a`.

If the request cannot connect, confirm that the CloudFormation deployment completed before running it again. Help me troubleshoot my protected endpoint test.

You have proved the endpoint is live and protected. The barrier now has a concrete job: admit authenticated tenant identities while keeping anonymous callers out.

Your protected API is deployed and rejecting anonymous requests. Next, you will prepare the shared database and create two tenant identities.

Bootstrap Two Tenants

The HTTPS route you deployed already rejects anonymous requests. It still has no tenant users or database rows to return.

This step uses the deployed AWS Lambda bootstrap function to create the shared schema with PostgreSQL Row-Level Security. It also creates two Amazon Cognito identities with different tenant claims.

In this step, get ready to:
  • Bootstrap the shared equipment database.
  • Create two users with different tenant claims.
  • Set permanent project passwords for both identities.
Bootstrap the shared database

The bootstrap function uses the RDS Data API to prepare the database without a direct network connection. Its statements create the table, database role, isolation policy, and sample rows.

A paused Amazon Aurora PostgreSQL Serverless v2 writer can take around 15 seconds to resume. A quiet terminal during that window is expected.

Before you invoke it, consider which database objects you expect the function to create.

  • Invoke the deployed BootstrapFunction by running this command:
aws lambda invoke --function-name $BootstrapFunctionName --cli-binary-format raw-in-base64-out --payload '{}' bootstrap-response.json --region af-south-1

What does this command do?

  • The function name comes from the existing $BootstrapFunctionName PowerShell variable.
  • The empty '{}' payload starts the administrative bootstrap without extra input.
  • The Lambda response is written to bootstrap-response.json in your current SAM project directory.
  • Read the saved Lambda response by running this command:
Get-Content bootstrap-response.json

What did the bootstrap create?

  • The public.equipment table stores an equipment ID, tenant ID, and equipment name.
  • The tenant_app role can read the equipment table through the public schema.
  • The tenant_isolation policy compares each row's tenant ID with current_setting('app.current_tenant', true).
  • The sample rows contain Haul Truck A1 for tenant A and Drill Rig B1 for tenant B.

You should see a response with status code 200 and the message Database bootstrap complete. That is the database foundation in place.

Bootstrap response missing?

Confirm that $BootstrapFunctionName still contains the deployed function name. Invoke the function again if Aurora was still resuming.

Check that you are still authenticated to the same AWS account in af-south-1.

Help me troubleshoot the database bootstrap.

Create two tenant identities

A tenant claim labels each identity with its organisation key. The two claim values give later API requests different database contexts.

  • Create tenant-a-user with the tenant-a claim by running the first command below.
  • Create tenant-b-user with the tenant-b claim by running the second command below.
aws cognito-idp admin-create-user --user-pool-id $UserPoolId --username tenant-a-user --user-attributes Name=custom:tenant_id,Value=tenant-a --message-action SUPPRESS --region af-south-1
aws cognito-idp admin-create-user --user-pool-id $UserPoolId --username tenant-b-user --user-attributes Name=custom:tenant_id,Value=tenant-b --message-action SUPPRESS --region af-south-1

How are the tenants separated?

  • The custom:tenant_id attribute becomes the tenant claim used by the application.
  • The first identity carries tenant-a.
  • The second identity carries tenant-b.
  • The --message-action SUPPRESS option keeps this project user creation under administrator control.
  • Review the tenant A command output.
  • Review the tenant B command output.

The first response should report tenant-a-user with custom:tenant_id set to tenant-a. The second response should report tenant-b-user with the attribute set to tenant-b.

User creation failed?

Confirm that $UserPoolId still contains the user pool output from the deployed stack. Check each username and tenant claim for typing differences.

A message saying the user already exists can appear after a repeated command. Continue only when that existing user has the intended tenant claim.

Help me troubleshoot Cognito user creation.

Set permanent project passwords

These are disposable project credentials that stay separate from every password you use elsewhere.

What must each password contain?

  • Each password has at least 12 characters.
  • Each password contains a lowercase letter.
  • Each password contains an uppercase letter.
  • Each password contains a number and a symbol.
  • Choose a unique project password for tenant-a-user that satisfies every rule above.
  • Choose a different project password for tenant-b-user that satisfies every rule above.
  • Store both project passwords in your password manager for the sign-in tests.
  • Replace the first <password> with the tenant A project password.
  • Replace the second <password> with the tenant B project password.
  • Apply both permanent passwords by running these commands:
aws cognito-idp admin-set-user-password --user-pool-id $UserPoolId --username tenant-a-user --password '<password>' --permanent --region af-south-1
aws cognito-idp admin-set-user-password --user-pool-id $UserPoolId --username tenant-b-user --password '<password>' --permanent --region af-south-1

What does permanent mean here?

The --permanent option immediately confirms each user's password. Both users are now ready for the password authentication flow in the next step.

  • Confirm that the tenant A password command returns to PowerShell without an error.
  • Confirm that the tenant B password command returns to PowerShell without an error.

A clean return to the PowerShell prompt confirms each password command completed.

Password rejected?

Check that each project password meets the configured length and character requirements. Use a different password for each tenant.

Confirm that each username matches the user created in the previous substep.

Help me fix a rejected project password.

Before the final check, consider which tenant claim should appear for each username.

  • Recheck the saved database bootstrap response by running this command:
Get-Content bootstrap-response.json

What does this final check prove?

The saved Database bootstrap complete message confirms that the database setup finished. The earlier Cognito outputs confirm that both tenant identities carry different claims.

  • Review the earlier output for tenant-a-user.
  • Review the earlier output for tenant-b-user.

The bootstrap response should contain Database bootstrap complete. The user outputs should show tenant-a for tenant A and tenant-b for tenant B.

Your shared table now has database-enforced tenant isolation. Next, you will sign in as each user and retrieve only that tenant's equipment.

Read Each Tenant's Equipment

Your shared equipment table and both tenant identities are ready. The protected endpoint still needs authenticated proof before it can connect a request to a tenant.

In this step, you will sign in through Amazon Cognito from Windows PowerShell. You will send each ID token through Amazon API Gateway to AWS Lambda and retrieve the matching equipment row.

In this step, get ready to:
  • Store an ID token for each Cognito user.
  • Call the protected tenant A endpoint with tenant A's token.
  • Call the protected tenant B endpoint with tenant B's token.
Sign in as both tenant users

A Cognito ID token is a JWT that carries validated identity claims. Each user's token includes a different custom:tenant_id value.

  • Return to the authenticated PowerShell window from earlier.
  • Replace your-tenant-a-project-password-here in the first command with tenant A's project test password.
  • Replace your-tenant-b-project-password-here in the second command with tenant B's project test password.
  • Store both ID tokens by running these commands:
$TenantAToken = aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH --client-id $UserPoolClientId --auth-parameters USERNAME=tenant-a-user,PASSWORD=your-tenant-a-project-password-here --query AuthenticationResult.IdToken --output text --region af-south-1
$TenantBToken = aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH --client-id $UserPoolClientId --auth-parameters USERNAME=tenant-b-user,PASSWORD=your-tenant-b-project-password-here --query AuthenticationResult.IdToken --output text --region af-south-1

How Do These Tokens Work?

  • The USER_PASSWORD_AUTH flow checks each user's username and project password through Cognito.
  • The --query AuthenticationResult.IdToken option keeps only the ID token from each authentication response.
  • PowerShell stores tenant A's token in $TenantAToken.
  • PowerShell stores tenant B's token in $TenantBToken.
  • Confirm PowerShell returns to the prompt without an AWS CLI error.

Sign-In Commands Failing?

  • Replace each password placeholder with the matching project test password.
  • Check that each username matches the tenant user created earlier.
  • Repeat the AWS CLI browser sign-in from Step 1 if your temporary AWS session has expired.

Help me troubleshoot Cognito user authentication.

Request tenant A's equipment

The API handler uses an RDS Data API transaction to keep tenant context attached to the query. PostgreSQL Row-Level Security uses that context to decide which shared-table rows are visible.

The first database request can take about 15 seconds if Amazon Aurora PostgreSQL Serverless v2 resumes from auto-pause. That short wait means the database is waking up.

  • Predict which equipment row tenant A can see.
  • Request tenant A's equipment by running this command:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-a" -Method Get -Headers @{ Authorization = "Bearer $TenantAToken" }

What Does This Request Do?

  • Invoke-RestMethod sends a GET request to the /equipment/tenant-a path.
  • The Authorization header supplies $TenantAToken as a bearer token.
  • The Lambda handler reads custom:tenant_id from the validated token claims.
  • The database transaction switches to tenant_app before setting app.current_tenant to tenant-a.
  • Inspect the response in PowerShell.

You have your first authenticated tenant result. PowerShell shows only Haul Truck A1 from the shared equipment table.

Tenant A Request Failing?

  • Repeat the tenant A sign-in command if its ID token is no longer accepted.
  • Check that $ApiUrl still contains the deployed API URL from Step 2.
  • Allow extra time for Aurora to resume if the request appears slow.

Help me troubleshoot the tenant A API request.

Request tenant B's equipment

Tenant B follows the same authenticated path into the shared table. Its token supplies a different tenant claim to the database transaction.

  • Predict whether tenant B receives the same equipment name or a different one.
  • Request tenant B's equipment by running this command:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-b" -Method Get -Headers @{ Authorization = "Bearer $TenantBToken" }

What Changes for Tenant B?

  • The request path asks for tenant-b equipment.
  • The $TenantBToken value supplies the tenant-b identity claim.
  • The RLS policy permits the row whose tenant_id matches the database session context.
  • Inspect the response in PowerShell.

Both tenant identities are now working through the protected API. PowerShell shows only Drill Rig B1 for tenant B.

  • Compare the two authenticated responses in PowerShell.

The tenant A response contains only Haul Truck A1. The tenant B response contains only Drill Rig B1.

Tenant B Result Missing?

  • Repeat the tenant B sign-in command if its ID token is no longer accepted.
  • Check that the request uses $TenantBToken instead of $TenantAToken.
  • Check that the request path ends with /equipment/tenant-b.

Help me troubleshoot the tenant B API result.

  • Scroll PowerShell until both sign-in commands are out of view.
  • Keep both equipment responses visible for your checkpoint.

Both identities can now read their own rows from one shared table. Next, you will deliberately point tenant A's token at tenant B's path and test whether the database blocks the mismatch.

Attempt Cross-Tenant Access

Your two Amazon Cognito identities can each read their own equipment record from the shared table. Now you will test the boundary that matters most.

An authenticated caller can edit the path parameter without changing the validated JWT. The decisive question is whether PostgreSQL Row-Level Security still blocks tenant B rows when tenant A asks for them.

In this step, get ready to:
  • Send the tenant A token to the tenant B path.
  • Compare that result with tenant B's valid request.
  • Retest tenant A's own path to rule out token expiry.
Test which tenant identity controls access

The URL path requests which tenant record to query. The token still carries tenant A's validated identity.

Before you run the request, make a mental prediction about whether tenant A will see tenant B's equipment.

  • Reuse the tenant A token against tenant B's path by running this command in Windows PowerShell:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-b" -Method Get -Headers @{ Authorization = "Bearer $TenantAToken" }

What should I see?

You should see no equipment rows in PowerShell. The response contains an empty JSON array.

The request reached a valid route. The database returned zero rows visible to tenant A.

Unexpected equipment or an authorization failure?

  • Check that the command uses $TenantAToken.
  • Confirm that the path ends with /equipment/tenant-b.
  • Repeat the sign-in from Step 4 if PowerShell reports an authorization failure.

Help me diagnose the cross-tenant request.

That's the decisive isolation check passed: tenant A named tenant B in the URL without exposing tenant B's row.

Compare the valid tenant B request

An empty response matters because tenant B data already exists behind the same path. Repeating the valid tenant B request provides that control result.

Before you run the comparison, make a mental prediction about what changes when only the token changes.

  • Repeat tenant B's valid request by running this command:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-b" -Method Get -Headers @{ Authorization = "Bearer $TenantBToken" }

What does the comparison prove?

You should see only Drill Rig B1 in the response. This confirms that the tenant B row exists behind the same path.

ApiFunction sets app.current_tenant from the JWT's custom:tenant_id claim. The tenant_isolation policy uses that session value inside the RDS Data API transaction.

The path supplies requested_tenant to the query. Row-Level Security limits which matching rows the transaction can read.

Tenant B's row missing?

  • Check that the command uses $TenantBToken.
  • Confirm that the path ends with /equipment/tenant-b.
  • Repeat tenant B's sign-in from Step 4 if PowerShell reports an authorization failure.

Help me restore the tenant B request.

Rule out token expiry

One final control uses the same tenant A token on its own path. This separates database isolation from token validity.

Before you run the final check, make a mental prediction about whether the unchanged token still works on tenant A's path.

  • Repeat tenant A's own request by running this command:
Invoke-RestMethod -Uri "$ApiUrl/equipment/tenant-a" -Method Get -Headers @{ Authorization = "Bearer $TenantAToken" }

What should I see?

You should see only Haul Truck A1 in the response. This confirms that the tenant A token remains valid.

The earlier empty result came from tenant isolation. Token expiry did not cause it.

Tenant A's row missing?

  • Confirm that the path ends with /equipment/tenant-a.
  • Repeat tenant A's sign-in from Step 4 if PowerShell reports an authorization failure.
  • Run the tenant A request again with the refreshed token.

Help me restore the tenant A request.

Your isolation proof now covers the attack and both control requests. A manipulated path cannot override the validated tenant claim.

Secret mission

Prove Aurora Auto-Pause

Prove that the Aurora writer reaches 0 ACUs during an idle period. Then wake it with an authenticated equipment request and confirm that capacity rises above zero.

Clean Up Your Resources

Clean Up Your Resources

The deployed AWS resources can continue to incur small charges until you delete the stack. Decide whether to keep the project available, pause active use, or remove its resources.

Cost warning

Amazon Aurora PostgreSQL Serverless v2 instance-capacity charges reach zero while the writer is auto-paused. Aurora storage can still incur charges.

Other persistent resources can also incur charges. Maintenance can wake the writer.

Choose Pause if you plan to return soon. Choose Delete to remove the project-managed persistent resources.

The AWS SAM managed artifact bucket sits outside this project stack. Keep it unless you have confirmed that no other SAM deployment uses it.

Resources you used:

  • The tenant-equipment-api AWS CloudFormation stack.
  • One VPC with two private subnets. One security group plus one database subnet group.
  • One Aurora PostgreSQL Serverless v2 cluster with one db.serverless writer.
  • One managed database secret in AWS Secrets Manager.
  • The equipmentdb database with the public.equipment table. It also contains the tenant_app role plus the tenant_isolation PostgreSQL Row-Level Security policy.
  • Two AWS Lambda functions with two IAM roles.
  • One Amazon Cognito user pool with an app client. It contains tenant-a-user plus tenant-b-user.
  • One Amazon API Gateway HTTP API with JWT authorization.
  • Local files in your existing Visual Studio Code workspace: template.yaml, src/app.py, src/bootstrap.py, src/requirements.txt, plus bootstrap-response.json.

Keep everything running

No action is needed. Choose this if you plan to continue testing the tenant-isolated API.

  • Continue using the authenticated tenant requests from the project.
  • Monitor the account for ongoing storage charges plus request-related charges.
  • Keep the shared AWS SAM managed artifact bucket for later SAM deployments.
  • Keep the local SAM files in your existing Visual Studio Code workspace.

Pause - I'll come back to this later

Shut down active use so the Aurora writer can return to 0 ACUs after five idle minutes. The persistent project resources remain available.

  • Stop sending requests to the equipment API.
  • Stop invoking the bootstrap Lambda function.
  • Stop making direct Amazon RDS Data API requests.
  • Allow at least five idle minutes for the configured auto-pause period.
  • Keep the local SAM files ready for your next session.

The writer can reach 0 ACUs after that idle period. Persistent resources can still incur charges.

Delete - I don't want to use this again

Deleting the stack permanently removes its project-managed cloud resources. Your shared AWS SAM artifact bucket stays available for later deployments.

  • Return to the PowerShell window from earlier.
  • Delete the stack and wait for AWS CloudFormation to finish by running these commands:
aws cloudformation delete-stack --stack-name tenant-equipment-api --region af-south-1
aws cloudformation wait stack-delete-complete --stack-name tenant-equipment-api --region af-south-1

What do these commands do?

  • The delete-stack command starts removing tenant-equipment-api from af-south-1.
  • The wait stack-delete-complete command keeps PowerShell waiting until AWS CloudFormation reports that deletion is complete.
  • Confirm that PowerShell returns to the prompt without an error.
  • Keep the shared AWS SAM managed artifact bucket unless you have confirmed that no other SAM deployment uses it.

That closes the chargeable part of the project: the project-managed AWS resources are gone.

Stack deletion not finishing?

  • Review the deletion details for tenant-equipment-api in AWS CloudFormation.
  • Find the first project resource that reports a deletion failure.
  • Help me troubleshoot this stack deletion.

The CloudFormation commands leave your local project files untouched. Remove those files separately if you no longer need the project.

  • Select template.yaml in the file sidebar of your existing Visual Studio Code workspace.
  • Hold Ctrl while selecting the src folder.
  • Keep Ctrl held while selecting bootstrap-response.json.
  • Press the Delete key.
  • Confirm the deletion if Visual Studio Code asks.
  • Verify that template.yaml, src, plus bootstrap-response.json no longer appear in the file sidebar.

Nice Work!

Nice Work!

You did it! You deployed a tenant-isolated equipment API on AWS in Africa (Cape Town). Its PostgreSQL database now blocks manipulated cross-tenant requests at the row level.

What you learned:

  • Provisioned an authenticated equipment API as repeatable infrastructure through AWS SAM backed by AWS CloudFormation.
  • Passed an Amazon Cognito tenant claim from Amazon API Gateway through AWS Lambda into an Amazon RDS Data API transaction. Applied Row-Level Security to the shared equipment table.
  • Proved cross-tenant isolation when tenant A's JWT returned an empty JSON array from tenant B's path.
  • Secret Mission: Used Amazon CloudWatch to observe Amazon Aurora PostgreSQL Serverless v2 at 0 ACUs. Sent an authenticated request that resumed the writer through the Data API. The API returned Haul Truck A1 after resume latency.

Ready to quiz yourself?