A guide to create (offline) multi-tenant apps with Expo and AWS Amplify
By Ramon Postulart
Ramon Postulart was IT engineering lead at ABN AMRO. In a previous article, he wrote a Guide on how to build implement Push Notifications with React Native, Expo, and AWS Amplify. This article is a guide on how to create multi-tenant apps. Starting with an explanation of a multi-tenant application, Ramon walks you through the whole process, enjoy!
Multi-tenant
A tenant is a group of users that have the same access rights. With multi-tenant, you design your application in a way that different tenants can make use of the same software without interfering with each other. They are in control of their own data assets without the risks that others can access it.
Software as a Service (SaaS)
There are different types of apps that we all use in our daily lives. One type of application is Software as a Service (SaaS) applications. The characteristics of SaaS is that the hardware and software are abstracted away from you. You don’t need to manage this, you only consume the features that are provided. You make free use of these apps or you take a subscription model where you pay a monthly/yearly fee. These applications are designed so that there is a software version running, which multiple users can use in a secure and isolated way.
Slack
You can see Slack as an example of a Multi-tenant SaaS Solution. The software and hardware are running somewhere (in their private networks or public cloud). As a company, you are able to get a subscription and add the users of your company. Your users can work together and share data in slack that is not accessible to other tenants.
Purpose of this guide
In this guide, I will explain how you can set up a multi-tenant application like Slack with AWS Amplify. The power of AWS Amplify is that it connects to different front end frameworks. So, as soon as you have set up the backend, you are able to build your front-end application on top of the multi-tenant backend.
We will build an Expo app. This is easy to set up and test your app. There are different possibilities of a multi-tenant implementation:
1) AWS account per tenant
2) Same AWS account and different DynamoDB tables per tenant
3) Same AWS account and one table for all tenants
I will focus on option 3 because this is a less complex implementation but still secure. To achieve the implementation there are again different options. These will be explained below.
The ideal implementation
The ideal implementation is the one where the server(less) side will check if the user is part of the tenant and if it is in the right cognito group. This can be done via the VTL templates in AWS Appsync.
Let’s assume we will create this model:
type channel
@model
@key(
name: "channelByTenant"
fields: ["tenant"]
queryField: "channelByTenant"
)
@auth(
rules: [
{ allow: owner, ownerField: "owner" }
{ allow: owner, ownerField: "tenant", identityClaim: "custom:tenant" }
{ allow: groups, groups: ["user"], operations: [read] }
{
allow: groups
groups: ["editors"]
operations: [create, read, update, delete]
}
]
) {
id: ID!
name: String!
messages: [message] @connection(keyName: "messageByChannel", fields: ["id"])
tenant: ID!
owner: ID!
}
A scheme like this is generated out of the box, as you can see in this schema: I apply 4 auth rules. The model shown above will allow the owner, tenants, and users in the group editors to create, read, update and delete and the users in the group users to read.
The problem with the generated template is that it is applying OR conditions on the rules, like:
#if( !($isStaticGroupAuthorized == true || $isDynamicGroupAuthorized == true || $isOwnerAuthorized == true) )
$util.unauthorized()
#end
Which means
$isStaticGroupAuthorized => check cognito groups
$isDynamicGroupAuthorized => check owners and tenants
$isOwnerAuthorized => not applicable now in the model
But we want this multi-tenant validation:
// if is not part of tenant, set unauthorized
if( tenant !== custom.tenantid ) {
unauthorized()
} // ELSE if is not the owner or part of the groups, set unauthorized
if( user !== owner || user is not in group users or editors) {
unauthorized()
}
Of course, it is possible to change all the VTL templates, but for one model there are many templates created (see next screenshot). If you are going to adjust them manually, you will end up with maintainability issues. Imagine if you have more models in your schema.
Each time you adjust your model, your templates are generated again and you have to apply the adjustments again.
Github issue
An issue issue is created, this will be solved:
https://github.com/aws-amplify/amplify-cli/issues/317
If this is implemented by the Amplify or Appsync team we can maintain the rules and tenant set up from the schema and based on that the templates will be generated.
Our model will look like this:
@auth(rules: [
{ and: [
{ allow: owner, ownerField: "tenant", identityClaim: "custom:tenant" }
{ or: [
{ allow: owner, ownerField: "owner" }
{ allow: groups, groups: ["user"], operations: [read] }
{ allow: groups groups: ["editors"] operations: [create, read, update, delete] }
]}
]}
])
Ok… what to do now?
I am very happy that datastore offers selective sync. This means that we only sync data between the cloud and our app that is part of the tenants. The other tenant data we will leave in the cloud and therefore it is not available to the clients.
AWS Services
I use different AWS services to accomplish this Multi-tenant set up:
- AWS Cognito: When a tenant creates an account will it be stored in Cognito. This is a service from AWS that handles user and access management for you. Cognito will create the user with a unique user id. This user id will be the tenant ID.
- AWS Amplify: AWS Amplify is a library that acts as the glue between front end tools and the backend on AWS. You can use AWS Amplify for setting up your APIs, storage, authentication and authorization, database, data store, and more. Via AWS Amplify we will design who can access what data and deploy the cloud backend. A great feature of AWS Amplify is the datastore. This is a capability that enables offline data access, manage the syncing of data between clients and the cloud and take care of syncing conflicts.
- AWS Appsync: AWS AppSync is a fully managed service that makes it easy to develop GraphQL APIs by handling the heavy lifting of securely connecting to data sources like AWS DynamoDB, Lambda, and more. When you make an API request the logic of the request is in the resolvers. A resolver has a request mapping and response mapping template. Via the response mapping template, we will apply the logic that checks if the user is in the right auth group to access the data and if he is part of the right tenant.
- AWS DynamoDB: DynamoDB is a NoSQL key-value pair database. It is a database that is completely managed for you and that will be deployed via the Appsync schema.
Getting Started
We are going to create a mini Slack app. As a tenant, you can add users and channels. The users in your tenant can create messages, only, in the channels which are part of the tenant.
Set up AWS Amplify
We first need to have the AWS Amplify CLI installed. The Amplify CLI is a command-line tool that allows you to create and deploy various AWS services.
To install the CLI, we’ll run the following command:
$ npm install -g @aws-amplify/cli
Next, we’ll configure the CLI with a user from our AWS account:
$ amplify configure
For a video walkthrough of the process of configuring the CLI, click here.
Set up React Native
First, we’ll create the React Native application we’ll be working with.
Run these commands in the root dir (so not in your reactJS dir)
$ npx expo init multitenantAPP> Choose a template: blank$ cd multitenantAPP$ npm install aws-amplify aws-amplify-react-native @react-native-community/netinfo
Init your Amplify Project
Now we can initialize a new Amplify project from within the root of our React Native application:
$ amplify init
Here we’ll be guided through a series of steps:
- Enter a name for the project: multitenantapp (or your preferred project name)
- Enter a name for the environment: dev (use this name, because we will reference it)
- Choose your default editor: Visual Studio Code (or your text editor)
- Choose the type of app that you’re building: javascript
- What javascript framework are you using: react-native
- Source Directory Path: src
- Distribution Directory Path: build
- Build Command: npm run-script build
- Start Command: npm run-script start
- Do you want to use an AWS profile? Y
- Please choose the profile you want to use: YOUR_USER_PROFILE
- Now, our Amplify project has been created & we can move on to the next steps.
Add Auth to your project
Amplify add auth
Follow these steps:
- Do you want to use the default authentication and security configuration? Manual configuration
- User Sign-Up, Sign-In, connected with AWS IAM controls (Enables per-user Storage features for images or other content, Analytics, and more)
- Please provide a friendly name for your resource that will be used to label this category in the project: Enter
- Please enter a name for your identity pool: Enter
- Allow unauthenticated logins? (Provides scoped down permissions that you can control via AWS IAM) No
- Do you want to enable 3rd party authentication providers in your identity pool? No
- Please provide a name for your user pool
- How do you want users to be able to sign in? Username
- Do you want to add User Pool Groups? Yes
- Provide a name for your user pool group: users
- Do you want to add another User Pool Group: Y
- Provide a name for your user pool group: editors
- Do you want to add another User Pool Group: N
- Sort the user pool groups in order of preference: Enter
- Do you want to add an admin queries API? No
- Multifactor authentication (MFA) user login options: OFF
- Email-based user registration/forgot password: Enabled(Requires per-user email entry at registration)
- Please specify an email verification subject: Enter
- Please specify an email verification message: Enter
- Do you want to override the default password policy for this User Pool? Enter
- What attributes are required for signing up? Select email and name by pressing the space bar and press enter when finished
- Specify the app’s refresh token expiration period (in days): Enter
- Do you want to specify the user attributes this app can read and write? Y
- Do you want to enable any of the following capabilities? Enter
- Do you want to enable any of the following capabilities? Select Add user to group
- Do you want to use an OAuth flow? No
- Do you want to configure Lambda Triggers for Cognito? N
- Enter the name of the group to which users will be added. users
- Do you want to edit your add-to-group function now? N
So that is it. We need to add a custom tenant id field now. Go to:
Amplify > backend > auth > multitenant......
open the parameters.json and replace this code:
"userpoolClientWriteAttributes": [
"email",
"name"
],
"userpoolClientReadAttributes": [
"email",
"name"
],
with this code:
"userpoolClientWriteAttributes": [
"email",
"name",
"custom:tenantid"
],
"userpoolClientReadAttributes": [
"email",
"name",
"custom:tenantid"
],
open the …..cloud formation-template.yml and replace this code:
Schema: -
Name: email
Required: true
Mutable: true -
Name: name
Required: true
Mutable: true
with this code:
Schema: -
Name: email
Required: true
Mutable: true -
Name: name
Required: true
Mutable: true -
Name: tenantid
Mutable: true
AttributeDataType: String
push to the cloud first:
Amplify push
Let’s create 4 users
Note: make sure you replace certain values with your own values. Do not apply quotes to string values.
Make a tenant user
Go to the terminal and apply this command:
aws cognito-idp admin-create-user --profile <profileName you made in the Set up AWS Amplify chapter > \
--user-pool-id <CognitoUserPoolID> \
--username <EMAIL> \
--temporary-password temppass \
--user-attributes
Name=email,Value=<EMAIL> \
--message-action SUPPRESS
make a note of the sub value, something like this: 0cc05b9c-9edf-4be8–87b6–25d7c4e750b6
This ID of the first user, the tenant, will be used when creating the next tenant users.
Make a tenant user in the group users
aws cognito-idp admin-create-user --profile <profileName you made in the Set up AWS Amplify chapter > \
--user-pool-id <CognitoUserPoolID> \
--temporary-password temppass \
--username <EMAIL> \
--user-attributes
Name=email,Value=<EMAIL>
\
--message-action SUPPRESS
Now update the attributes:
aws cognito-idp admin-update-user-attributes --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --user-attributes Name=custom:tenantid,Value=<ID THAT YOU HAVE NOTED> Name=name,Value=<A NAME>
Add this user to the “users” group
aws cognito-idp admin-add-user-to-group --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --group-name users
Make a tenant user in the group editors
aws cognito-idp admin-create-user --profile <profileName you made in the Set up AWS Amplify chapter > \
--user-pool-id <CognitoUserPoolID> \
--temporary-password temppass \
--username <EMAIL> \
--user-attributes
Name=email,Value=<EMAIL>
\
--message-action SUPPRESS
Now update the attributes:
aws cognito-idp admin-update-user-attributes --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --user-attributes Name=custom:tenantid,Value=<ID THAT YOU HAVE NOTED> Name=name,Value=<A NAME>
Add this user to the “users” group
aws cognito-idp admin-add-user-to-group --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --group-name editors
Make a user in another tenant in the group editors
aws cognito-idp admin-create-user --profile <profileName you made in the Set up AWS Amplify chapter > \
--user-pool-id <CognitoUserPoolID> \
--temporary-password temppass \
--username <EMAIL> \
--user-attributes
Name=email,Value=<EMAIL>
\
--message-action SUPPRESS
Now update the attributes:
aws cognito-idp admin-update-user-attributes --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --user-attributes Name=custom:tenantid,Value=1 Name=name,Value=<A NAME>
Add this user to the “users” group
aws cognito-idp admin-add-user-to-group --profile <profileName> --user-pool-id <CognitoUserPoolID> --username <EMAIL> --group-name editors
Great job! You have set up your authentication and you have created a tenant with two end-users and a complete other tenant user. We will first create some data via the Appsync console and validate our implementation before we are going to build the front end application. But before we can do that we first need to deploy our API.
Add GraphQL API to your project
Amplify add api
These steps will take place:
- Select GraphQL
- Enter a name for the API: multitenantapp (your preferred API name)
- Select an authorization type for the API: Amazon Cognito User Pool (Because we are using this app with authenticated users only, but you can choose other options)
- Select at do you want to use the default authentication and security configuration: Default configuration
- How do you want users to be able to sign in? Username (with this also the AWS Amplify Auth module will be enabled)
- Do you want to configure advanced settings? Yes, I want to make some additional changes
- Configure additional auth types? N
- Configure conflict detection? Y
- Select the default resolution strategy Auto merge
- Do you want to override default per model settings? N
- Do you have an annotated GraphQL schema? n
- Choose a schema template: Single object …
- Do you want to edit the schema now?: n
Your API and your schema definition have been created now. You can find it in your project directory:
Amplify > backend > api > name of your api
Open the schema.graphql file and replace the code with this code.
type channel
@model
@key(
name: "channelByTenant"
fields: ["tenant"]
queryField: "channelByTenant"
)
@auth(
rules: [
{ allow: owner, ownerField: "owner" }
{ allow: groups, groups: ["users"], operations: [read] }
{
allow: groups
groups: ["editors"]
operations: [create, read, update, delete]
}
]
) {
id: ID!
name: String!
messages: [message] @connection(keyName: "messageByChannel", fields: ["id"])
tenant: ID!
owner: ID!
}type message
@model
@key(
name: "messageByChannel"
fields: ["channel"]
queryField: "messageByChannel"
)
@auth(
rules: [
{ allow: owner, ownerField: "owner" }
{ allow: groups, groups: ["users"], operations: [read, create] }
{
allow: groups
groups: ["editors"]
operations: [read, update, create, delete]
}
]
) {
id: ID!
channel: ID!
user: ID!
username: String!
message: String!
tenant: ID!
owner: ID!
}
Your backend is set up and can be pushed to the cloud, please run:
amplify push
Follow these steps:
- Are you sure you want to continue? Y
- Do you want to generate code for your newly created GraphQL API? Y
- Choose the code generation language target? Javascript
- Enter the file name pattern of GraphQL queries, mutations and subscriptions ENTER
- Do you want to generate/update all possible GraphQL operations — queries, mutations, and subscriptions Y
- Enter maximum statement depth [increase from the default if your schema is deeply nested]? 2
Now create the datastore models which you use in your app. Run this code in the root of your app:
amplify codegen models
Test via the Appsync console
Please log in to the console. Go to the Appsync console, select your project and click on queries.
If you first log in with the user from the editor group and create a channel and a few messages. Then logout and login with the user from the other tenant. You will notice that you can see the data of the other tenant.
This is because we don’t have the right validation in place in our VTL templates as I mentioned before. I will update this section as soon as the GitHub issue has been solved.
So for now we will set up this tenant filter via our application. I wanted to show this because although we set multi-tenant via the app, everybody still has access if they want.
Set up your React Native App
As you are used from me, I don’t focus much on the UX of an application during my blogs. I just want you to show how the basic functionality is working.
Run first
yarn add @react-navigation/native @react-navigation/stack
and
expo install react-native-gesture-handler react-native-reanimated react-native-screens react-native-safe-area-context @react-native-community/masked-view
Make a file metro.config.js in the root of your project and paste this code.
module.exports = {
resolver: {
blacklistRE: /#current-cloud-backend\/.*/
}
};
This will prevent metro to identify duplicate packages and cause errors.
Go to the app.js in the React Native directory and paste this code.
This file will manage the navigation and logout actions
import React from "react";
import { View, Button } from "react-native";
import Amplify, { Auth } from "aws-amplify";
import { withAuthenticator } from "aws-amplify-react-native";
import { DataStore } from "@aws-amplify/datastore";
import Channels from "./src/channels";
import ChannelMessages from "./src/channelMessages";
import LoadAuth from "./src/auth";
import { NavigationContainer } from "@react-navigation/native";
import { createStackNavigator } from "@react-navigation/stack";
import awsconfig from "./src/aws-exports"; // if you are using Amplify CLI//Amplify.configure(awsconfig);
Amplify.configure({
...awsconfig,
Analytics: {
disabled: true
}
});const Stack = createStackNavigator();async function logout() {
await DataStore.clear();
Auth.signOut();
}function Appstart() {
return (
<View
style={{
marginTop: 40, display: "flex",
flex: 1
}}
>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Auth" component={LoadAuth} />
<Stack.Screen
name="Channel"
component={Channels}
options={{
headerLeft: null,
headerTitle: "Channels",
headerRight: () => (
<Button onPress={() => logout()} title="Logout" color="#000" />
)
}}
/>
<Stack.Screen name="Messages" component={ChannelMessages} />
</Stack.Navigator>
</NavigationContainer>
</View>
);
}export default withAuthenticator(Appstart);
Create this file auth.js in the src directory.
This file identify if a user is authenticated and if so set the datastore selective sync with the tenantid of the user and redirect to the channel overview.
import { Auth } from "aws-amplify";
import { DataStore, syncExpression } from "@aws-amplify/datastore";
import { Channel, Message } from "./models";
import { useNavigation } from "@react-navigation/native";let tenantid = "";// use for development purposes, to reset the datastore during each app start
DataStore.clear();
DataStore.configure({
syncExpressions: [
syncExpression(Channel, () => {
return c => c.tenant("eq", tenantid);
}),
syncExpression(Message, () => {
return c => c.tenant("eq", tenantid);
})
]
});function Datastore() {
const navigation = useNavigation();
const initDS = async () => {
await Auth.currentAuthenticatedUser()
.then(async result => {
if (result !== "not authenticated") {
tenantid = result.attributes["custom:tenantid"];
await DataStore.start();
navigation.navigate("Channel");
}
})
.catch(err => {
console.log(err);
});
}; initDS(); return null;
}export default Datastore;
and create a file channels.js in the src directory and paste this code:
import React, { useEffect, useState } from "react";
import {
View,
Text,
SafeAreaView,
FlatList,
StyleSheet,
StatusBar,
TouchableHighlight,
TouchableOpacity
} from "react-native";
import { DataStore } from "@aws-amplify/datastore";
import { Auth } from "aws-amplify";
import { Channel } from "./models/";
import { useNavigation } from "@react-navigation/native";const Item = ({ id, name, navigateToMessage }) => (
<TouchableOpacity onPress={() => navigateToMessage(id, name)}>
<View style={styles.item}>
<Text style={styles.title}>{name}</Text>
</View>
</TouchableOpacity>
);export default function Channels() {
let subscription;
const [channels, setChannels] = useState([]);
const navigation = useNavigation(); const navigateToMessage = (id, name) => {
navigation.navigate("Messages", { id, name });
}; const loadChannelArray = async () => {
const result = await DataStore.query(Channel); setChannels(result);
}; useEffect(() => {
loadChannelArray();
subscription = DataStore.observe(Channel).subscribe(() => {
loadChannelArray();
});
return function cleanup() {
setChannels([]);
subscription.unsubscribe();
};
}, []); const renderItem = ({ item }) => (
<Item
name={item.name}
id={item.id}
navigateToMessage={() => navigateToMessage(item.id, item.name)}
/>
); const onSubmit = async () => {
const auth = await Auth.currentAuthenticatedUser(); const identifier = new Date(); await DataStore.save(
new Channel({
name: "New channel " + identifier.getSeconds(),
owner: auth.signInUserSession.accessToken.payload.sub,
tenant: auth.attributes["custom:tenantid"]
})
); loadChannelArray();
}; return (
<SafeAreaView style={styles.container}>
<View
style={{
alignItems: "flex-end"
}}
>
<TouchableHighlight onPress={() => onSubmit()}>
<Text
style={{
fontSize: 16,
alignItems: "flex-end"
}}
>
Add new channel
</Text>
</TouchableHighlight>
</View>
<FlatList
data={channels}
renderItem={renderItem}
keyExtractor={item => item.id}
/>
</SafeAreaView>
);
}const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: StatusBar.currentHeight || 0
},
item: {
backgroundColor: "#dead31",
padding: 20,
marginVertical: 8,
marginHorizontal: 16
},
title: {
fontSize: 32
}
});
Create a file channelMessages.js in the src directory and paste this code:
This file will manage the messages of a channel
import React, { useEffect, useState } from "react";
import {
View,
Text,
SafeAreaView,
FlatList,
StyleSheet,
StatusBar,
TouchableHighlight
} from "react-native";
import { DataStore } from "@aws-amplify/datastore";
import { Auth } from "aws-amplify";
import { Message } from "./models/";const Item = ({ username, message }) => (
<View style={styles.item}>
<Text style={styles.title}>Name {username}</Text>
<Text style={styles.title}>{message}</Text>
</View>
);export default function Messages(props) {
const id = props.route.params.id; const [messages, setMessages] = useState([]); async function loadMessagesArray() {
const result = await DataStore.query(Message, c => c.channel("eq", id));
setMessages(result);
} useEffect(() => {
const loadMessages = async () => {
loadMessagesArray();
}; loadMessages();
}, []); const renderItem = ({ item }) => (
<Item username={item.username} message={item.message} />
); const onSubmit = async () => {
const auth = await Auth.currentAuthenticatedUser(); const identifier = new Date(); await DataStore.save(
new Message({
channel: id,
user: auth.signInUserSession.accessToken.payload.sub,
username: auth.attributes.name,
message: "This is a new message " + identifier.getSeconds(),
owner: auth.signInUserSession.accessToken.payload.sub,
tenant: auth.attributes["custom:tenantid"]
})
); loadMessagesArray();
}; return (
<SafeAreaView style={styles.container}>
<View
style={{
alignItems: "flex-end"
}}
>
<TouchableHighlight onPress={() => onSubmit()}>
<Text
style={{
fontSize: 16,
alignItems: "flex-end"
}}
>
Add new message
</Text>
</TouchableHighlight>
</View>
<FlatList
data={messages}
renderItem={renderItem}
keyExtractor={item => item.id}
/>
</SafeAreaView>
);
}const styles = StyleSheet.create({
container: {
flex: 1,
marginTop: StatusBar.currentHeight || 0
},
item: {
backgroundColor: "#5d90e3",
padding: 20,
marginVertical: 8,
marginHorizontal: 16
},
title: {
fontSize: 18
}
});
You have set up your complete application now. If your run:
expo start
Press I when loaded, this will start Expo on your IOS simulator. Log in with the different users and notice the difference between tenants and between users in different groups.
Repo
You can find my repo on GitHub.
Conclusion
I have shown you how with tools like React Native (Expo) and AWS Amplify you can set up Multi-tenant applications.
Of course, this is not the ideal set up yet, because the conditional checks are missing from the VTL templates. We can manually add those, but from a maintainability perspective, we only want to do this from the schema. As soon as there is an update, I will update this blog.
I hope this was useful to you, if you have any feedback let me know!