How to isolate tenant data on DynamoDB in a real world use case
Introduction
In the early stages of the development of a multi-tenant SaaS (Software as a Service) — when you start to treat the data of the different tenants in the database and the security of this data, the many ways of how to do it can get you confused on what is the “correct” or “easier”. Here i will show how i did this on my serverless multi-tenant SaaS based completely on the AWS architecture and tools.
Tools
The tools below were used to build the software, completely serverless and full deployed in the AWS.
- Lambda & API Gateway: respectively where our handler functions run (TypeScript + Node.js 20.x used) and how we access the functions.
- AWS Cognito & IAM: User access and identity management.
- DynamoDB: powerful and fast No SQL key-value serverless database.
- AWS STS: formerly Security Token System, the main agent behind the roles and policies manipulation.
- AWS SAM: formerly Serverless Application Model, how we manipulate and deploy our software to AWS (optional).
Setup
Assumed Prerequisites
- The request to the API already have a Identity Token or some other form to identify the tenant/user who made the request.
- The user/tenant already have own data in the database.
Guide
First of all, we will create the IAM Roles & Policies that we will use (and need) to make all of this work.
Lambda Authorizer Role: AuthorizerRole
Permissions Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"apigateway:*",
"sts:TagSession"
],
"Resource": "*",
"Effect": "Allow"
}
]
}
Trust Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}
]
}
Then we need to create our IAM Role & Policy that have the read access to our database.
Dynamo Read Access Role: DynamoGetItem
Permissions Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Action": [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:Scan", ],
"Effect": "Allow",
"Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Products",
"Condition": {
"ForAllValues:StringEqualsIfExists": {
"dynamodb:LeadingKeys": "${aws:PrincipalTag/TenantList}"
}
}
}
]
}
Trust Policy
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com",
"AWS": "arn:aws:sts::123456789012:assumed-role/AuthorizerRole/*"
},
"Action": [
"sts:AssumeRole",
"sts:TagSession"
]
}
]
}
The Lambda Authorizer role need sts:TagSession
to provide to the function read role policy the data via session tags. And the sts:AssumeRole
to use the STS SDK AssumeRole command.
The function read role need in his Trust Relationship have as a principal the AuthorizerRole and both the before mentioned STS permissions.
And mainly, the read role must have in a Condition section of the policy, the dynamodb:LeadingKeys
— wich represents the Partition Key of the item in the database. This condition in runtime will be evaluated to the item key and compared with the provided via session tags.
Now we need to create our Lambda Authorizer, the actor that will enforce the permissions over the handler function that recover our data.
In this below snippet will not explain the basic functionalities of the Lambda Authorizer, for the more basic parts please follow this guide.
import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts";
import { Handler } from "aws-lambda";
export const Authorizer: Handler = async (event, context, callback) => {
// Change the below section to your identification method.
const identityToken = event.headers['Identity'];
if (!identityToken) {
// Your data validation
}
const tenantList = jwtDecode(identityToken)['tenantList'];
// Session Tags building
const sessionTags = [
{
Key: "TenantList",
Value: `${tenantList}` // ["tenant1", "tenant2"]
}
]
const stsClient = new STSClient({ region: <your-region> });
const assumeRoleCommand = new AssumeRoleCommand({
RoleArn: "arn:aws:iam::123456789012:role/DynamoGetItem",
RoleSessionName: "UniqueSessionIdentifier",
Tags: sessionTags,
TransitiveTagKeys: ["TenantList"]
})
const assumedCredentials = await stsClient
.send(assumeRoleCommand)
.Credentials;
callback(null, {
<default lambda authorizer response>,
context: {
...context,
credentials: {
accessKeyId: assumedCredentials.AccessKeyId,
secretAccessKey: assumedCredentials.SecretAccessKey,
sessionToken: assumedCredentials.SessionToken
}
}
})
}
Next, we will setup our handler responsible for recovering the data from the database. We will be using the DynamoDB SDK GetItem command.
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
export const GetProduct: Handler = (event, context, callback) => {
const productId = event.pathParameters.id;
const authorizerCredentials = event.authorizer.credentials;
const dynamoClient = new DynamoClient({
region: <your region>,
credentials: authorizerCredentials
});
const getItemCommand = new GetItemCommand({
TableName: "Products",
Key: { S: productId }
});
return await dynamoClient.send(getItemCommand);
}
Conclusion
Now with all our resources done and deployed, the user should only have access to data that is owned by the tenants that they have access/is part of.
Thanks for reading & happy coding! 👾
Vitor E.