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

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

n part three of the series about the world of BBQ, where tradition and technology rarely cross paths. The future of grilling is here, and it’s connected, smart, and runs on the cloud! I continue with tenant management using an serverless and event-driven approach with EventBridge, StepFunctions, API Gateway, Lambda, and Cognito User Pools.

Published Aug 23, 2024
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.

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.
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.

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.
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.
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.

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.
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.
Next we create the Lambda functions that will back out application API.
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.

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.
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.

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.
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.

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.

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.

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. If you enjoy the quiz part go and check out kvist.ai
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!
 

Comments