Pricing Tiers
In this guide, we'll go over how you can enforce access to features in a SaaS application with multiple pricing tiers using Warrant.
What is Tiered SaaS?
Tiered SaaS is a common pricing model used in modern B2B SaaS products. It allows product owners to segment their users into multiple buckets with access to different product features depending on their pricing tier. For example, you might have a 'Free' tier that gives your users access to basic features and a paid 'Pro' tier that offers additional add-ons for power users.
For this guide, let's assume you've built a SaaS application similar to Figma that allows users to create and manage design assets within their teams. It's a B2B app so users are grouped into 'organizations' that represent their company or team.
Let's say this new app has a variety of features including a collaborative editor
, projects
and analytics
. Access to these features depends on an organization's pricing tier. For example, an organization at the 'Pro' tier has access to projects
whereas organizations in the 'Free' tier do not.
The full tier to feature mapping in our application is as follows:
Free | Pro | Enterprise |
---|---|---|
Collaborative Editor | Everything in Free | Everything in Pro |
Projects | Analytics |
In the remainder of this guide, we'll go over how you can use Warrant to create these pricing tiers and enforce user access based on organization and tier within the app.
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 Quickstart to set up your account.
Creating Object Types
In our application, each user belongs to an organization and each organization is associated with a 'Free', 'Pro' or 'Enterprise' tier that grants them access to specific features. In order to enforce these constraints in our app, we need to be able to answer these queries:
- Which
feature(s)
belong to the propricing-tier
? - Which
pricing-tier
is anorganization
subscribed to? - Which
organization
does a givenuser
belong to?
These queries are all based on relationships between 3 object types: feature
, pricing-tier
and organization
. So let's first create these object types in Warrant.
Feature
Let's start by creating the feature
object type. Features only have a subscriber
relation which allows us to grant access to specific features (ex. free tier 'subscriber' to the collaborative editor
). A user is a subscriber
of a feature either directly or if they're a member of a pricing-tier
the feature belongs to.
Create the feature
object type as follows:
- JSON
- cURL
{
"type": "feature",
"relations": {
"subscriber": {
"inheritIf": "anyOf",
"rules": [
{
"inheritIf": "member",
"ofType": "pricing-tier",
"withRelation": "member"
}
]
}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "feature",
"relations": {
"subscriber": {
"inheritIf": "anyOf",
"rules": [
{
"inheritIf": "member",
"ofType": "pricing-tier",
"withRelation": "member"
}
]
}
}
}'
Tier
Next, we can create the tier
object type. Similar to feature, a tier has one relation, member
. We can use this relation to associate which organizations are members of a specific tier (ex. org1 is a 'member' of the Free tier
).
Create the tier
object type as follows:
- JSON
- cURL
{
"type": "pricing-tier",
"relations": {
"member": {}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "pricing-tier",
"relations": {
"member": {}
}
}'
Organization
Lastly, we'll create an organization
object type. Similar to tier and feature, organization has one relation, member
. We can use this relation to express which users are members of an organization (ex. user1 is a 'member' of org1
).
Create the organization
object type as follows:
- JSON
- cURL
{
"type": "organization",
"relations": {
"member": {}
}
}
curl "https://api.warrant.dev/v1/object-types" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"type": "organization",
"relations": {
"member": {}
}
}'
Creating Warrants
Now that our object types are defined, we need to create some warrants to establish the specific relationships between our tiers, features, organizations and users.
Some of these warrants only need to be created once (ex. feature to tier mappings). Others, like 'user to organization' and 'organization to tier' warrants, need to be integrated into our app's new user and tier management logic.
Setting up Feature Tiers
Our feature to tier warrants can be created and managed directly through the Warrant dashboard since we don't expect our features and pricing to change that often.
However, we can still create them via API as follows:
Free Tier
The 'Free' tier only has access to the collaborative editor
feature. We can express this through 1 warrant:
- cURL
# Free tier has access to the 'collaborative_editor' feature
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "feature",
"objectId": "collaborative_editor",
"relation": "subscriber",
"subject": {
"objectType": "tier",
"objectId": "free",
"relation": "member"
}
}'
Pro Tier
The 'Pro' tier has access to all 'Free' tier features as well as access to the projects
feature. We can express this through 2 warrants:
- cURL
# Pro tier members are also Free tier members
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "tier",
"objectId": "free",
"relation": "member",
"subject": {
"objectType": "tier",
"objectId": "pro",
"relation": "member"
}
}'
# Pro tier has access to the 'projects' feature
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "feature",
"objectId": "projects",
"relation": "subscriber",
"subject": {
"objectType": "tier",
"objectId": "pro",
"relation": "member"
}
}'
Enterprise Tier
The 'Enterprise' tier has access to all Pro and Free tier features as well as access to the analytics
feature. We can express this through 3 warrants:
- cURL
# Enterprise tier members are also Free tier members
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "tier",
"objectId": "free",
"relation": "member",
"subject": {
"objectType": "tier",
"objectId": "enterprise",
"relation": "member"
}
}'
# Enterprise tier members are also Pro tier members
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "tier",
"objectId": "pro",
"relation": "member",
"subject": {
"objectType": "tier",
"objectId": "enterprise",
"relation": "member"
}
}'
# Enterprise tier has access to the 'analytics' feature
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "feature",
"objectId": "analytics",
"relation": "subscriber",
"subject": {
"objectType": "tier",
"objectId": "enterprise",
"relation": "member"
}
}'
Associating Users with Organizations
In order to properly manage feature access for users based on their organization, Warrant needs to know every user's organization. The easiest way to hook this up is by adding the following code to the newUserSignUp()
handler in the app:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
# 'USER' is the newly created user, 'ORG' is user's intended organization
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "organization",
"objectId": ORG,
"relation": "member",
"subject": {
"objectType": "user",
"objectId": USER
}
}'
// 'user' is the newly created user, 'org' is user's intended organization
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "organization",
ObjectId: org,
Relation: "member",
Subject: warrant.Subject{
ObjectType: "user",
ObjectId: user,
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
// 'user' is the newly created user, 'org' is user's intended organization
try {
Subject warrantSubject = new Subject("user", user)
Warrant warrantToCreate = new Warrant("organization", org, "member", warrantSubject)
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
// 'user' is the newly created user, 'org' is user's intended organization
const newWarrant = await warrantClient.Warrant.Create({
object: {
objectType: "organization",
objectId: org,
},
relation: "member",
subject: {
objectType: "user",
objectId: user,
},
});
# 'user' is the newly created user, 'org' is user's intended organization
subject = Subject("user", user)
client.create_warrant(object_type="organization", object_id=org, relation="member", subject=subject)
begin
Warrant::Warrant.create(
object_type: "organization",
object_id: org,
relation: "member",
subject: {
object_type: "user",
object_id: user
}
)
rescue
# Handle error
end
Associating Organizations with Feature Tiers
Now that we have our features and tiers defined, and we've associated users with organizations, we're almost done. One of the remaining steps is to ensure that Warrant is aware of every organization's pricing tier. The easiest way to hook this up is by adding the following code to the newSubscription()
handler in the app:
- cURL
- Go
- Java
- Node.js
- Python
- Ruby
# 'ORG' is the organization, 'TIER' is the intended tier
curl "https://api.warrant.dev/v1/warrants" \
-X POST \
-H "Authorization: ApiKey YOUR_KEY" \
--data-raw \
'{
"objectType": "tier",
"objectId": "TIER",
"relation": "member",
"subject": {
"objectType": "organization",
"objectId": "ORG",
"relation": "member"
}
}'
// 'org' is the organization and 'tier' is the org's intended tier
resp, err := client.CreateWarrant(warrant.Warrant{
ObjectType: "tier",
ObjectId: tier,
Relation: "member",
Subject: warrant.Subject{
ObjectType: "organization",
ObjectId: org,
Relation: "member",
},
})
if err != nil {
fmt.Println(err.Error())
} else {
fmt.Println(resp)
}
try {
Subject warrantSubject = new Subject("organization", org, "member");
Warrant warrantToCreate = new Warrant("tier", tier, "member", warrantSubject);
client.createWarrant(warrantToCreate);
} catch (WarrantException e) {
// Handle error
}
// 'org' is the organization and 'tier' is the org's intended tier
const newWarrant = await warrantClient.Warrant.create({
object: {
objectType: "tier",
objectId: tier,
},
relation: "member",
subject: {
objectType: "organization",
objectId: org,
relation: "member",
},
});
subject = Subject("organization", org, "member")
client.create_warrant(object_type="tier", object_id=tier, relation="member", subject=subject)
begin
Warrant::Warrant.create(
object_type: "tier",
object_id: tier,
relation: "member",
subject: {
object_type: "organization",
object_id: org,
relation: "member"
}
)
rescue
# Handle error
end
Enforcing User Access to Features
Now that our access model is set up, the final step is to start enforcing access checks in our app.
For example, you might want to check if a user with userId user1
is authorized to access the analytics
feature. To enforce this check, you'd need to add the following access check code in the app:
- 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": "feature",
"objectId": "analytics",
"relation": "subscriber",
"subject": {
"objectType": "user",
"objectId": "user1"
}
}
]
}'
isAuthorized, _ := client.IsAuthorized(warrant.WarrantCheckParams{
Warrants: []warrant.Warrant{
{
ObjectType: "feature",
ObjectId: "analytics",
Relation: "subscriber",
Subject: warrant.Subject{
ObjectType: "user",
ObjectId: "user1",
},
}
}
})
if isAuthorized {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
Subject warrantSubject = new Subject("user", "user1")
Warrant warrantToCheck = new Warrant("feature", "analytics", "subscriber", warrantSubject)
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: "feature",
objectId: "analytics",
},
relation: "subscriber",
subject: {
objectType: "user",
objectId: "user1",
},
},
],
});
if (isAuthorized) {
// Allow access to 'analytics' for this user
} else {
// Fail request
}
is_authorized = client.Authz.check("feature", "analytics", "subscriber", Subject("user", "user1"))
if is_authorized:
# Allow access to 'analytics' for this user
else:
# Fail request
unless Warrant::Warrant.is_authorized?(
warrants: [
{
object_type: "feature",
object_id: "analytics",
relation: "subscriber",
subject: {
object_type: "user",
object_id: "user1"
}
}
])
# User Unauthorized
end
Summary
In this guide, we created an access model for a B2B SaaS application that allows us to enforce user access to specific features based on their organization and pricing tier (Free, Pro or Enterprise).
Defining this model in Warrant allows us to separate our access logic from our core business logic and enables us to make changes on the fly without having to change application code.
With Warrant, we can 'upgrade' and 'downgrade' an organization's pricing tier and also add/remove features from pricing tiers instantly without having to change any application code.