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:
- JSON
- cURL
{
"type": "report",
"relations": {
"owner": {},
"editor": {
"inheritIf": "owner"
},
"viewer": {
"inheritIf": "editor"
}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"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:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
# '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",
"subject": {
"objectType": "user",
"objectId": "ID"
}
}'
// '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",
Subject: warrant.Subject{
ObjectType: "user",
ObjectId: 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
try {
Subject warrantSubject = new Subject("user", userId);
Warrant warrantToCreate = client.createWarrant(new Warrant("tenant", org, "member", warrantSubject));
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
// '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,
},
});
# 'org' is the tenant and 'userId' is the id of the new user
client.create_warrant(object_type="tenant", object_id=org, relation="member", subject=Subject("user", userId))
# 'org' is the tenant and 'userId' is the id of the new user
Warrant::Warrant.create(
object_type: "tenant",
object_id: org,
relation: "member",
subject: {
object_type: "user",
object_id: 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:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
# 'ROLE' is the roleId, 'ID' is the id of the user to add to the role
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "role",
"objectId": "ROLE",
"relation": "member",
"subject": {
"objectType": "user",
"objectId": "ID"
}
}'
// 'ROLE' is the role, 'userId' is the id of the user to add to the role
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "role",
ObjectId: ROLE,
Relation: "member",
Subject: warrant.Subject{
ObjectType: "user",
ObjectId: userId,
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
// 'ROLE' is the role, 'userId' is the id of the user to add to the role
try {
Subject warrantSubject = new Subject("user", userId);
Warrant warrantToCreate = Warrant.createWarrant(new Warrant("role", ROLE, "member", warrantSubject));
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
// '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,
},
});
# 'ROLE' is the role, 'userId' is the id of the user to add to the role
client.create_warrant(object_type="role", object_id=ROLE, relation="member", subject=Subject("user", userId))
# 'ROLE' is the role, 'userId' is the id of the user to add to the role
Warrant::Warrant.create(
object_type: "role",
object_id: ROLE,
relation: "member",
subject: {
object_type: "user",
object_id: 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:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
# Grant all members of the 'ADMIN' role 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",
"subject": {
"objectType": "role",
"objectId": "ADMIN",
"relation": "member"
}
}'
// Grant all members of the 'admin' role editor access to 'reportA'
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "report",
ObjectId: "reportA",
Relation: "editor",
Subject: warrant.Subject{
ObjectType: "role",
ObjectId: "admin",
Relation: "member",
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
// Grant all members of the 'admin' role editor access to 'reportA'
try {
Subject warrantSubject = new Subject("role", "admin", "member");
Warrant warrantToCreate = Warrant.createWarrant(new Warrant("report", "reportA", "editor", warrantSubject));
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
// 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",
},
});
# Grant all members of the 'admin' role editor access to 'reportA'
client.create_warrant(object_type="report", object_id="reportA", relation="editor", subject=Subject("role", "admin", "member"))
# Grant all members of the 'admin' role editor access to 'reportA'
Warrant::Warrant.create(
object_type: "report",
object_id: "reportA",
relation: "editor",
subject: {
object_type: "role",
object_id: "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:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
curl "https://api.warrant.dev/v2/authorize" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"warrants": [
{
"objectType": "report",
"objectId": "reportA",
"relation": "editor",
"subject": {
"objectType": "user",
"objectId": "user1"
}
}
]
}'
isAuthorized, _ := client.IsAuthorized(warrant.WarrantCheckParams{
Warrants: []warrant.Warrant{
{
ObjectType: "report",
ObjectId: "reportA",
Relation: "editor",
Subject: warrant.Subject{
ObjectType: "user",
ObjectId: "user1",
},
}
}
})
if isAuthorized {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
Subject subject = new Subject("user", "user1")
Warrant warrantToCheck = new Warrant("report", "reportA", "editor", subject)
boolean isAuthorized = client.isAuthorized(new WarrantCheck(Arrays.asList(warrantToCheck)));
if (isAuthorized) {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
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
}
is_authorized = client.Authz.check("report", "reportA", "editor", Subject("user", "user1"))
if is_authorized:
# Allow access to 'analytics' for this user
else:
# Fail request
unless Warrant::Warrant.is_authorized?(
warrants: [
{
object_type: "report",
object_id: "reportA",
relation: "editor",
subject: {
object_type: "user",
object_id: "user1"
}
}
])
# User Unauthorized
end
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.