Skip to main content

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 and/or data 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 and easier to deploy and maintain (just 1 instance!) and is becoming more commonly accepted by enterprises, 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. users with the admin role).

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 (including built-in object types) and thus assumes you have a Warrant account with API keys. If you don't, please first follow the Quickstart to set up your account.

Creating Object Types

Let's start by defining our app's main 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 'roles' within that tenant (ex. the admin role).

In addition to tenant and role, 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(s) does a given user belong to?
  • Which role(s) does a given user belong to?
  • Who can edit and/or view a given report?

We need 3 object types in order to define these relationships: tenant, role, and report. Luckily, Warrant ships with built-in types for tenant and role so we only need to define the report object type. But for the sake of completeness, we'll review all 3 object types below.

Tenant

Let's start with the built-in 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:

{
"type": "tenant",
"relations": {
"owner": {},
"member": {
"inheritIf": "owner"
}
}
}

Role

Roles help us categorize users within a tenant. As an example, we might have 'admin' and 'support' roles in one of our tenants. Let's say that all users in the 'admin' role can 'create reports' and all users in the 'support' role can 'view all reports'. By granting these permissions to the appropriate role, we can implement basic role based access control (RBAC).

Given that RBAC is so common, Warrant includes a pre-defined object type for role that we can use. The role type is defined as follows and includes relations for owner, editor, viewer and member:

{
"type": "role",
"relations": {
"member": {
"inheritIf": "anyOf",
"rules": [
{
"inheritIf": "member",
"ofType": "role",
"withRelation": "member"
}
]
}
}
}

Report

The tenant and role object types ensure that we can properly categorize our users into their respective tenants and 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:

{
"type": "report",
"relations": {
"owner": {},
"editor": {
"inheritIf": "owner"
},
"viewer": {
"inheritIf": "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, roles, 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:

// 'org' is the tenant and 'userId' is the id of the new user
const newWarrant = await warrantClient.Warrant.create({
object: {
objectType: "tenant",
objectId: org,
},
relation: "member",
subject: {
objectType: "user",
objectId: userId,
},
});

Associating Users with Roles

In addition to associating users with tenants, we need to add logic that lets us add users to roles within their tenants. For example, we might want to add a user to the 'admin' role in order to grant elevated access. Assuming we have logic in our app to manage role membership, we also need to add the following code:

// 'ROLE' is the role, 'userId' is the id of the user to add to the role
const newWarrant = await warrantClient.Warrant.create({
object: {
objectType: "role",
objectId: ROLE,
},
relation: "member",
subject: {
objectType: "user",
objectId: userId,
},
});

Managing Reports

In addition to creating warrants for tenant and role 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 role (ex. admin role) access to 'edit' a report. In this case, we would need to add the following code in our app:

// Grant all members of the 'admin' role editor access to 'reportA'
const newWarrant = await warrantClient.Warrant.create({
object: {
objectType: "report",
objectId: "reportA",
},
relation: "editor",
subject: {
objectType: "role",
objectId: "admin",
relation: "member",
},
});

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' role which has the ability to edit reportA. We can query Warrant for this check from our code as follows:

const isAuthorized = await warrantClient.Authorization.Check({
warrants: [
{
object: {
objectType: "report",
objectId: "reportA",
},
relation: "editor",
subject: {
objectType: "user",
objectId: "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 role membership dynamically and ensure that objects in the app (ex. reports) are only accessible to users in the appropriate tenant and role.