Multi-Tenant SaaS
In this guide, we'll go over how you can build a flexible authorization scheme for a multi-tenant B2B SaaS application using Warrant.
What is Multi-Tenant SaaS?
Multi-tenant SaaS has become the default architecture for most B2B SaaS apps today. By B2B SaaS apps, we mean any cloud-based software used by multiple users or teams within an organization.
In a multi-tenant setup, all tenants (or accounts) are hosted on a single instance of the application with data separation primarily enforced in the application layer. This is in contrast to a single-tenant setup where each tenant receives its own instance of the application, including separate data stores and web services.
While a multi-tenant architecture is cheaper, easier to deploy and maintain (just 1 instance!) and more commonly accepted by enterprise customers today, data security becomes a pretty important application-level concern. Let's take Mixpanel as an example. If I'm a user of Mixpanel, I want assurance that my product analytics data (raw events, reports etc.) is only accessible by my team and me in Mixpanel. Furthermore, I might also want to have more guardrails in my account and restrict access to certain data and reports to a handful of people (ex. a group of admins).
In the remainder of this guide, we'll go over how you can use Warrant to model a B2B multi-tenant app like Mixpanel and ensure proper authorization and access control to data based on tenant and role/permissions.
Prerequisites
This guide exclusively uses the Warrant API and thus assumes you have a Warrant account with API keys. If you don't, please first follow the Getting Started guide to set up your account.
Creating Object Types
Let's define our app's resources and relationships. Like Mixpanel, our app supports multiple users per tenant. Each user can only belong to 1 tenant but can also belong to 1 or more 'groups' within that tenant (ex. the admin group).
In addition to tenant and group, our app has a resource type called 'report'. A report is an instance of data and visualizations that can be created, edited and viewed by users in a tenant based on their roles and permissions. Reports cannot be accessed by users outside of their tenant.
To summarize then, these are the following relationships that we need to be able to resolve in our app:
- Which
tenant
does a givenuser
belong to? - Which
group(s)
does a givenuser
belong to? - Who can edit and/or view a given
report
?
We need 3 object types in order to define these relationships: tenant, group, and report. Let's create these types in Warrant.
Tenant
Let's start by creating the tenant
object type. A tenant can have an owner
(likely the user who created the account or an account/IT admin). In addition to an owner, tenants have members
, or all the other users that are part of the tenant. This gives us the following object type definition:
- JSON
- cURL
{
"type": "tenant",
"relations": {
"owner": {},
"member": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "owner"
}
]
}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "tenant",
"relations": {
"owner": {},
"member": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "owner"
}
]
}
}
}'
Group
Groups can help us categorize users within a tenant. As an example, we might have 'admin' and 'support' groups in one of our tenants. Let's say that all users in the 'admin' group can 'create reports' and all users in the 'support' group can 'view all reports'. By granting these permissions to the appropriate group, we can implement basic role based access control (RBAC).
Given that RBAC is so common, Warrant includes a pre-defined group
object type that we can use. The type comes with a member
relationship which can grant group membership to users.
If this type didn't already exist, we could create it as follows:
- JSON
- cURL
{
"type": "group",
"relations": {
"member": {}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "group",
"relations": {
"member": {}
}
}'
Report
Defining tenant
and group
ensures that we can properly categorize our users into their respective tenants and groups/roles. Now we need to define the concept of a report
, the main resource in our system.
Like in Mixpanel, reports in our system can be created, edited and viewed. But the set of users that can take each of these actions depends. For one, each report has an owner
, usually the user that created it. Otherwise, a report can have multiple editors (including the owner) and multiple viewers (including the editors). Putting this all together, we can create the report
object type as follows:
- JSON
- cURL
{
"type": "report",
"relations": {
"owner": {},
"editor": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "owner"
}
]
},
"viewer": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "editor"
}
]
}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "report",
"relations": {
"owner": {},
"editor": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "owner"
}
]
},
"viewer": {
"type": "anyOf",
"rules": [
{
"type": "userset",
"relation": "editor"
}
]
}
}
}'
Creating & Managing Warrants
Now that our object types are defined, we need to add some logic in our code that will create warrants to establish the specific relationships between our tenants, groups, reports and users. Let's review these scenarios and see what logic we need to add in our app.
Associating Users with Tenants
One of the first things we need to do is add logic to associate new users with their tenant
. We can do this by creating a warrant that associates a new user with their tenant on creation. The easiest way to hook this up is by adding the following code to our app's newUser()
creation flow:
- cURL
- Node.js
- Go
- Python
- Java
# 'ORG' is the tenant, 'ID' is the id of the new user
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "tenant",
"objectId": "ORG",
"relation": "member",
"user": {
"userId": "ID"
}
}'
// 'org' is the tenant and 'userId' is the id of the new user
client
.createWarrant("tenant", org, "member", { userId: userId })
.then((newWarrant) => console.log(newWarrant))
.catch((error) => console.log(error));
// 'org' is the tenant and 'userId' is the id of the new user
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "tenant",
ObjectId: org,
Relation: "member",
User: warrant.WarrantUser{
UserId: userId,
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
# 'org' is the tenant and 'userId' is the id of the new user
client.create_warrant(object_type="tenant", object_id=org, relation="member", user={userId=userId})
// 'org' is the tenant and 'userId' is the id of the new user
try {
Warrant warrantToCreate = Warrant.newUserWarrant("tenant", org, "member", userId);
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
Associating Users with Groups
In addition to associating users with tenants, we need to add logic that lets us add users to groups within their tenants. For example, we might want to add a user to an 'admin' group in order to grant elevated access. Assuming we have logic in our app to manage group membership, we also need to add the following code:
- cURL
- Node.js
- Go
- Python
- Java
# 'GROUP' is the group, 'ID' is the id of the user to add to the group
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "group",
"objectId": "GROUP",
"relation": "member",
"user": {
"userId": "ID"
}
}'
// 'group' is the group, 'userId' is the id of the user to add to the group
client
.createWarrant("group", group, "member", { userId: userId })
.then((newWarrant) => console.log(newWarrant))
.catch((error) => console.log(error));
// 'group' is the group, 'userId' is the id of the user to add to the group
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "group",
ObjectId: group,
Relation: "member",
User: warrant.WarrantUser{
UserId: userId,
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
# 'group' is the group, 'userId' is the id of the user to add to the group
client.create_warrant(object_type="group", object_id=group, relation="member", user={userId=userId})
// 'group' is the group, 'userId' is the id of the user to add to the group
try {
Warrant warrantToCreate = Warrant.newUserWarrant("group", group, "member", userId);
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
Managing Reports
In addition to creating warrants for tenant and group membership, we need to create warrants that help us manage the reports in our app. For example, when a report is created, we should create a warrant that associates the user who created the report as being the owner
of that report. We might also want to grant a particular group (ex. admin group) access to 'edit' a report. In this case, we would need to add the following code in our app:
- cURL
- Node.js
- Go
- Python
- Java
# Grant all members of the 'ADMIN' group editor access to 'REPORTA'
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "report",
"objectId": "REPORTA",
"relation": "editor",
"user": {
"objectType": "group",
"objectId": "ADMIN",
"relation": "member"
}
}'
// Grant all members of the 'admin' group editor access to 'reportA'
client
.createWarrant("report", "reportA", "editor", { objectType: "group", objectId: "admin", relation: "member" })
.then((newWarrant) => console.log(newWarrant))
.catch((error) => console.log(error));
// Grant all members of the 'admin' group editor access to 'reportA'
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "report",
ObjectId: "reportA",
Relation: "editor",
User: warrant.WarrantUser{
Userset: &warrant.Userset{
ObjectType: "group",
ObjectId: "admin",
Relation: "member",
},
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
# Grant all members of the 'admin' group editor access to 'reportA'
client.create_warrant(object_type="report", object_id="reportA", relation="editor", user={objectType="group", objectId="admin", relation="member"})
// Grant all members of the 'admin' group editor access to 'reportA'
try {
Userset userset = new Userset("group", "admin", "member");
Warrant warrantToCreate = Warrant.newUsersetWarrant("report", "reportA", "editor", userset);
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
Checking User Access
Now that our access model and warrants are set up, we can add access checks in our app that will help us enforce our multi-tenant setup. For example, we might want to check if a particular user (user1) has the ability to edit a report (reportA). The answer to this query will depend on multiple factors including user1's tenant and whether they are part of the 'admin' group which has the ability to edit reportA. We can query Warrant for this check from our code as follows:
- cURL
- Node.js
- Go
- Python
- Java
curl "https://api.warrant.dev/v1/authorize" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "report",
"objectId": "reportA",
"relation": "editor",
"user": {
"userId": "user1"
}
}'
client
.isAuthorized("report", "reportA", "editor", "user1")
.then((isAuthorized) => {
if (isAuthorized) {
// Allow access to 'analytics' for this user
}
})
.catch((error) => console.log(error));
isAuthorized, _ := client.IsAuthorized(warrant.Warrant{
ObjectType: "report",
ObjectId: "reportA",
Relation: "editor",
User: warrant.WarrantUser{
UserId: "user1",
},
})
if isAuthorized {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
is_authorized = client.is_authorized(object_type="report", object_id="reportA", relation="editor", user_to_check="user1")
if is_authorized:
# Allow access to 'analytics' for this user
else:
# Fail request
boolean isAuthorized = client.isAuthorized(Warrant.newUserWarrant("report", "reportA", "editor", "user1"));
if (isAuthorized) {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
Summary
In this guide, we created a flexible authorization scheme for a multi-tenant B2B SaaS application using Warrant. Although not completely exhaustive, this scheme is extendable and at least allows us to manage tenant and group membership dynamically and ensure that objects in the app (ex. reports) are only accessible to users in the appropriate tenant and group.