DEV Community

Cover image for Building a serverless connected BBQ as SaaS - Part 3 - Tenants
Jimmy Dahlqvist for AWS Community Builders

Posted on • Originally published at jimmydqv.com

Building a serverless connected BBQ as SaaS - Part 3 - Tenants

The time has come for Part 3 in the series of creating a Serverless Connected BBQ as SaaS. In this third post we'll look into tenant creation, authentication, and authorization. We will create a new tenant service and tie it together with the user service from part two.

If you have not already checked it out, here is part one and part two

Tenants in a SaaS

In a Software as a Service (SaaS) solution, a tenant is the organization or individual that subscribe to and use the service.

One of the most crucial parts of an multi-tenant SaaS solutions, is tenant data isolation, ensuring that each tenant’s data remains separate and secure from others within the same environment. This is especially important in maintaining data privacy, complying with regulations, and protecting against data breaches.

Approaches for Tenant Data Isolation in AWS

To isolate tenant data there are several different approaches we can use.

Dedicated database / data store per tenant

Each tenant has a separate database instance or data store, like S3. This provides the strongest isolation since each tenant’s data is entirely segregated at the database / data store level. In this approach we can also use separate KMS keys for each tenant, making sure that data is secured even on an encryption level.

Some of the challenges with this approach would be higher cost due to multiple database instances. Scaling can become complex as the number of tenants grows.

Table per tenant

Each tenant has a separate table in the database. For a data store like S3 each tenant can have a unique prefix that data is stored under. To use separate KMS keys data need to be encrypted on the client level and not on the storage level, which can add even more isolation.

This approach provides a good compromise between cost and isolation. and easier to enforce data isolation at the table level.

Some of the challenges with this approach are that it still requires careful management of table names and schema evolution. Performance could be impacted.

Row-Level Security (RLS)

Data for all tenants is stored in a single table, but with row-level access controls to ensure that each tenant can only access their own data. This access control can be on the data storage level with built in RLS or on a client level with authorization checks with services like Amazon Verified Permissions. For a data store like S3 each tenant still can have a unique prefix that data is stored under.

Some of the challenges with this approach are that it's more complex to enforce correctly; any bugs in logic could expose data. Performance can be impacted by complex query filtering.

What approach should I use?

The approach you use depends on your use case, level of compliance you need, and customer requirements.

In this series of BBQ data I will take the approach and use row-level security.

Architecture Overview

In this architecture we continue to build on the event-driven setup introduced in part 2. When a user signs up, is created, an event will be sent from the user service onto an EventBridge event-bus, this will now invoke the tenant service. The tenant service has two primary DynamoDB tables, one that store the tenant information, ID, name, etc and one that map users access to a tenant. The service will start by creating an tenant ID store it and then map the user to this new tenant.

Image showing tenant creation overview

Our tenant service can be called via two separate APIs. One for our web application that will fetch and update the tenant information, and one admin API that will not only be used by SaaS admins but also for inter service communication. We will introduce the first round of API authorization in this post, IAM for machine-2-machine and Oauth and JWT token validation for the web application API. We will dig deeper into both the APIs and authorization further down in this post.

Image showing tenant creation overview

Tenant creation

First let's create the tenant creation resources needed. Here we create two new DynamoDB tables. In one table we store the tenants and information for it like tenant name etc. In the second table we will store a mapping between tenants and users for access. We also need to query which tenants a user has access to, for this we also setup a index for the table.

AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Connected BBQ Application Tenant Service
Parameters:
  ApplicationName:
    Type: String
    Description: Name of owning application
    Default: bbq-iot
  CommonStackName:
    Type: String
    Description: The name of the common stack that contains the EventBridge Bus and more

  TenantTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub ${ApplicationName}-tenants
      AttributeDefinitions:
        - AttributeName: tenantid
          AttributeType: S
      KeySchema:
        - AttributeName: tenantid
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST

  TenantUserTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub ${ApplicationName}-tenant-users
      AttributeDefinitions:
        - AttributeName: tenantid
          AttributeType: S
        - AttributeName: userid
          AttributeType: S
      KeySchema:
        - AttributeName: tenantid
          KeyType: HASH
        - AttributeName: userid
          KeyType: RANGE
      GlobalSecondaryIndexes: 
        - IndexName: user-index
          KeySchema:
            - AttributeName: userid
              KeyType: HASH
            - AttributeName: tenantid
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      BillingMode: PAY_PER_REQUEST
Enter fullscreen mode Exit fullscreen mode

When a user is created we need to create the tenant that this user owns. User service will send an event on the application event-bus when a user is created, so here we keep building on a event-driven saga pattern and create the tenant.

Using StepFunctions makes it easy to write to DynamoDB and post an event for the next part to take over.

Image showing tenant creation step functions

After the tenant has been created and stored we need to map the first admin user to it, basically the user who owns the tenant. This StepFunction is invoked by the event that the tenant was created.

Image showing tenant user mapping step functions

Final step in this StepFunction also publish to EventBridge for future use.

Let's add these to StepFunctions to our template.


  TenantCreateExpress:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: create-tenant-statemachine/statemachine.asl.yaml
      Tracing:
        Enabled: true
      Logging:
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt TenantCreateStateMachineLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
      DefinitionSubstitutions:
        EventBridgeBusName:
          Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        TenantTable: !Ref TenantTable
        ApplicationName: !Ref ApplicationName
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - logs:*
              Resource: "*"
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        - DynamoDBCrudPolicy:
            TableName: !Ref TenantTable
      Events:
        CreateTenantEvent:
          Type: EventBridgeRule
          Properties:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
            Pattern:
              source:
                - !Sub ${ApplicationName}.user
              detail-type:
                - created
      Type: EXPRESS


  TenantAddFirstAdminExpress:
    Type: AWS::Serverless::StateMachine
    Properties:
      DefinitionUri: add-tenant-first-admin-statemachine/statemachine.asl.yaml
      Tracing:
        Enabled: true
      Logging:
        Destinations:
          - CloudWatchLogsLogGroup:
              LogGroupArn: !GetAtt TenantAddFirstAdminStateMachineLogGroup.Arn
        IncludeExecutionData: true
        Level: ALL
      DefinitionSubstitutions:
        EventBridgeBusName:
          Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        TenantUserTable: !Ref TenantUserTable
        ApplicationName: !Ref ApplicationName
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - logs:*
              Resource: "*"
        - EventBridgePutEventsPolicy:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
        - DynamoDBCrudPolicy:
            TableName: !Ref TenantUserTable
      Events:
        CreateTenantEvent:
          Type: EventBridgeRule
          Properties:
            EventBusName:
              Fn::ImportValue: !Sub ${CommonStackName}:eventbridge-bus-name
            Pattern:
              source:
                - !Sub ${ApplicationName}.tenant
              detail-type:
                - created
      Type: EXPRESS
Enter fullscreen mode Exit fullscreen mode
Comment: Tenant service - Create Tenant On User Created
StartAt: Debug
States:
  Debug:
    Type: Pass
    Next: Genetate Tenant ID
  Genetate Tenant ID:
    Type: Pass
    Parameters:
      tenantid.$: States.UUID()
    ResultPath: $.TenantID
    Next: Create Tenant
  Create Tenant:
    Type: Task
    Resource: arn:aws:states:::dynamodb:putItem
    Parameters:
      TableName: ${TenantTable}
      Item:
        tenantid:
          S.$: $.TenantID.tenantid
    ResultPath: null
    Next: Prepare Event
  Prepare Event:
    Type: Pass
    Parameters:
      tenantId.$: $.TenantID.tenantid
      email.$: $.detail.email
      userName.$: $.detail.userName
      name.$: $.detail.name
    ResultPath: $.TenantData
    Next: Post Event
  Post Event:
    Type: Task
    Resource: arn:aws:states:::events:putEvents
    Parameters:
      Entries:
        - Source: ${ApplicationName}.tenant
          DetailType: created
          Detail.$: $.TenantData
          EventBusName: ${EventBridgeBusName}
    End: true
Enter fullscreen mode Exit fullscreen mode
Comment: Tenant Service - Create Tenant Admin User Mapping
StartAt: Debug
States:
  Debug:
    Type: Pass
    Next: Create Tenant User Mapping
  Create Tenant User Mapping:
    Type: Task
    Resource: arn:aws:states:::dynamodb:putItem
    Parameters:
      TableName: ${TenantUserTable}
      Item:
        tenantid:
          S.$: $.detail.tenantId
        userid:
          S.$: $.detail.userName
        name:
          S.$: $.detail.name
        email:
          S.$: $.detail.email
        role: admin
    ResultPath: null
    Next: Prepare Event
  Prepare Event:
    Type: Pass
    Parameters:
      tenantId.$: $.detail.tenantId
      email.$: $.detail.email
      userName.$: $.detail.userName
      name.$: $.detail.name
      role: admin
    ResultPath: $.TenantUser
    Next: Post Event
  Post Event:
    Type: Task
    Resource: arn:aws:states:::events:putEvents
    Parameters:
      Entries:
        - Source: ${ApplicationName}.tenant
          DetailType: adminUserAdded
          Detail.$: $.TenantUser
          EventBusName: ${EventBridgeBusName}
    End: true
Enter fullscreen mode Exit fullscreen mode

APIs

The tenant service have two separate APIs. First of all an API that is used by the web application to load, display, and update information about the tenant. This API will be central in coming parts as we extend how users get access to tenants. The second API is a management admin API that can be used for special service and system integration.

For the application API we will use the tokens issued by Cognito to authorize the users, and for the management API we will use machine-2-machine authorization with AWS Iam, at least for no.

Both of the APIs will be backed by Lambda functions for implementation of the business logic.

To start let us create the application API. Users will call the API with the token they got from Cognito. API Gateway have three Lambda integrations that will fetch the correct data, and we have a Lambda Authorizer to validate the token.

Image showing tenant application API

Let's start by adding the API definition to the template, we will use AWS::Serverless::Api resources and these MUST be defined in the same template as the Lambda functions for us to easy create the integration.


  TenantAPi:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub ${ApplicationName}-tenant-api
      StageName: prod
      EndpointConfiguration: REGIONAL
      Cors:
        AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
        AllowOrigin: "'*'"
      Auth:
        AddDefaultAuthorizerToCorsPreflight: false
        Authorizers:
          LambdaRequestAuthorizer:
            FunctionArn: !GetAtt LambdaApiAuthorizer.Arn
            FunctionPayloadType: REQUEST
            Identity: 
              Headers:
                - Authorization
          LambdaUserRequestAuthorizer:
            FunctionArn: !GetAtt LambdaApiUserAuthorizer.Arn
            FunctionPayloadType: REQUEST
            Identity: 
              Headers:
                - Authorization
        DefaultAuthorizer: LambdaRequestAuthorizer

Enter fullscreen mode Exit fullscreen mode

Next we create the Lambda functions that will back out application API.

  LambdaTenantInfoGet:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: Lambda/Api/TenantInfoGet
      Handler: get.handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TenantTable
      Environment:
        Variables:
          DYNAMODB_TABLE_NAME: !Ref TenantTable
      Events:
        GetTenantInfoApi:
          Type: Api
          Properties:
            Path: /tenant/{tenantId}
            Method: get
            RestApiId: !Ref TenantAPi

  LambdaTenantInfoPut:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: Lambda/Api/TenantInfoPut
      Handler: put.handler
      Policies:
        - DynamoDBCrudPolicy:
            TableName: !Ref TenantTable
      Environment:
        Variables:
          DYNAMODB_TABLE_NAME: !Ref TenantTable
      Events:
        PutTenantInfoApi:
          Type: Api
          Properties:
            Path: /tenant/{tenantId}
            Method: put
            RestApiId: !Ref TenantAPi

  LambdaGetTenants:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: Lambda/Api/TenantsList
      Handler: get.handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TenantUserTable
      Environment:
        Variables:
          DYNAMODB_TABLE_NAME: !Ref TenantUserTable
          DYNAMODB_INDEX_NAME: user-index
      Events:
        GetTenantsForUserApi:
          Type: Api
          Properties:
            Path: /tenants/{userId}
            Method: get
            RestApiId: !Ref TenantAPi
            Auth:
              Authorizer: LambdaUserRequestAuthorizer
Enter fullscreen mode Exit fullscreen mode

Next we can move over to the management API, which will AWS Iam to authorize the calls and have one Lambda based integration to fetch all tenants for a user.

Image showing tenant management API

  AdminTenantAPi:
    Type: AWS::Serverless::Api
    Properties:
      Name: !Sub ${ApplicationName}-tenant-admin-api
      StageName: prod
      EndpointConfiguration: REGIONAL
      Cors:
        AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
        AllowOrigin: "'*'"
      Auth:
        DefaultAuthorizer: AWS_IAM

  LambdaGetTenantsForUser:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: Lambda/Internal/GetTenantForUser
      Handler: handler.handler
      Policies:
        - DynamoDBReadPolicy:
            TableName: !Ref TenantUserTable
      Environment:
        Variables:
          DYNAMODB_TABLE_NAME: !Ref TenantUserTable
          DYNAMODB_INDEX_NAME: user-index
      Events:
        GetTenantsForUserApi:
          Type: Api
          Properties:
            Path: /tenants/{userId}
            Method: get
            RestApiId: !Ref AdminTenantAPi
            Auth:
              AuthorizationType: AWS_IAM
Enter fullscreen mode Exit fullscreen mode

Cors cors cors everywhere

Let me start by quoting the one and only Eric Johnson. If CORS had a face I would punch it in its nose that basically sums up my feeling around CORS.

First of all we need to ser CORS on our API, by specifying AllowMethods, AllowHeaders, AllowOrigin on our API resource, this will add OPTIONS allowing preflight fetching from the browser.

TenantAPi:
    Type: AWS::Serverless::Api
    Properties:
    ......
      Cors:
        AllowMethods: "'GET,PUT,POST,DELETE,OPTIONS'"
        AllowHeaders: "'Content-Type,Authorization,X-Amz-Date,X-Api-Key,X-Amz-Security-Token'"
        AllowOrigin: "'*'"
Enter fullscreen mode Exit fullscreen mode

If you set a authorizer on the API resource and you don't explicit set AddDefaultAuthorizerToCorsPreflight: false authorization will be added to OPTIONS which in most cases will make the preflight fail generating a cors error in the browser.

But, since we use a proxy Lambda integration setting cors on the API is not enough. The Lambda function is responsible for setting and returning the cors headers, so we need to add them to the return in our functions as well.

return {
    "statusCode": 200,
    "body": json.dumps(response["Items"]),
    "headers": {
        "Access-Control-Allow-Origin": "*",  # Allow all origins
        "Access-Control-Allow-Headers": "Content-Type,Authorization",
        "Access-Control-Allow-Methods": "GET,POST,OPTIONS,PUT,DELETE",  # Allowed methods
    },
}
Enter fullscreen mode Exit fullscreen mode

User authorization

In this part we slowly start to introduce APIs and for that we of course need some form of user authorization. We will use Lambda Authorizer on the public web application API. In the first version and fairly primitive functionality we will rely on the User Pool enriching the JWT token with a tenant ID.

Cognito Pre Token Generation

To add custom claims and enrich the JWT token we hook into the User Pool authentication flow and use the Pre Token Generation hook. Right now I'm using the version 1 of this hook which will add the claims to the ID Token. It was not that long ago that the possibility to customize the access token was added. In later parts we will expand on this and move over to version 2 of the event which will customize both the ID and access token.

During the process we will call the tenant API and fetch the tenant ID for the user and add this to the token.

Image showing pre token generation

Now, when using the version 1 event it's still only possible to add custom string values, it's not possible to add arrays, which I feel is a bit of a drawback.

For machine-2-machine authorization we will rely on AWS IAM, which means we need to sign our requests using sigv4.

import boto3
import os
import json
import requests
from requests_aws4auth import AWS4Auth

api_endpoint = os.environ.get("TENANT_API_ENDPOINT")

def handler(event, context):
    user_id = event["request"]["userAttributes"]["sub"]
    try:
        # Get AWS credentials
        session = boto3.Session()
        credentials = session.get_credentials().get_frozen_credentials()

        # Set up AWS4Auth using the credentials
        region = os.environ["AWS_REGION"]
        auth = AWS4Auth(
            credentials.access_key,
            credentials.secret_key,
            region,
            "execute-api",
            session_token=credentials.token,
        )

        response = requests.get(api_endpoint + "/tenants/" + user_id, auth=auth)

        if response.status_code == 200:
            tenants = response.json()["tenants"]

            event["response"]["claimsOverrideDetails"] = {
                "claimsToAddOrOverride": {"tenant": tenants[0]}
            }
            return event
        else:
            print(f"Error fetching tenant: {response.status_code}")

    except requests.RequestException as e:
        # Handle any exceptions that occur during the API call
        print(f"Error making API request: {str(e)}")

    return event
Enter fullscreen mode Exit fullscreen mode

Custom Lambda Authorizers

To authorize the calls and ensure that the user making the call to fetch tenant data, actually has access to that tenant, we use a custom Lambda Authorizer on our API. We will rely on the User Pool adding the tenant ID to the JWT token during the authentication process. We will therefor decode the JWT token, and validate the signature of the token, and read out the tenant id from the token. The tenant ID in the token will then be checked against the tenant the user try to access. It's a bit primitive but it works for our current use-case.

In later parts of this series we will create a central authorization service that will validate and authorize calls in all parts of the system.

import os
import json
import jwt
from jwt import PyJWKClient


def handler(event, context):
  token = event["headers"].get("authorization", "")

    if not token:
        raise Exception("Unauthorized")

    token = token.replace("Bearer ", "")
    try:
        path_tenant_id = event["pathParameters"]["tenantId"]

        jwks_url = os.environ["JWKS_URL"]
        jwks_client = PyJWKClient(jwks_url)
        signing_key = jwks_client.get_signing_key_from_jwt(token)

        # Decode the JWT and validate the signature
        decoded_token = jwt.decode(
            token,
            signing_key.key,
            algorithms=["RS256"],
            audience=os.environ["AUDIENCE"],
        )

        token_tenant_id = decoded_token.get("tenant")

        if token_tenant_id == path_tenant_id:
            return generate_policy(
                decoded_token["sub"], "Allow", event["methodArn"], decoded_token
            )

    except Exception as e:
        print(f"Authorization error: {str(e)}")
        raise Exception("Unauthorized")

    # Generate a default policy that deny access
    return generate_policy(
        decoded_token["sub"], "Deny", event["methodArn"], decoded_token
    )

def generate_policy(principal_id, effect, resource, context):
    auth_response = {
        "principalId": principal_id,
        "policyDocument": {
            "Version": "2012-10-17",
            "Statement": [
                {"Action": "execute-api:Invoke", "Effect": effect, "Resource": resource}
            ],
        },
        "context": context,
    }
    return auth_response
Enter fullscreen mode Exit fullscreen mode

Dashboard

We extend the dashboard with one more page that show the tenant information, and that have the possibility to set a new name on the tenant.

Image showing pre token generation

Get the code

The complete setup with all the code is available on Serverless Handbook

Final Words

This was a post where I go through part three in the series about connected BBQ as Saas and discuss tenants and tenant creation.

Check out My serverless Handbook for some of the concepts mentioned in this post.

Don't forget to follow me on LinkedIn and X for more content, and read rest of my Blogs

As Werner says! Now Go Build!

Top comments (1)

Collapse
 
jasondunn profile image
Jason Dunn [AWS]

Awesome level of detail!