Guide to RBAC for B2B SaaS

Guide to RBAC for B2B SaaS

Role based access control (RBAC) is a method of answering the question “Can a user do this thing?”

If you’ve ever used a product where some users are "Admins" or "Owners", and they can do more than everyone else, you’ve used a product with RBAC.

Github, for example, has 5 roles (Owners, Members, Moderators, Billing managers, Security managers) and the roles have clearly defined permissions for what users with those roles can do:

RBAC is something that seems really simple at face value, because it typically does start off simple. Over time, however, as customers have more unique requests and as your product gets more complex, the once-simple authorization system can be a lot more error prone.

In this post, we’ll talk about how you can approach roles & permissions for your product, how roles (RBAC) tends to change as companies get bigger and products get more complex, best practices around user roles, and the common edge cases we see - especially when you start selling to enterprises.

Roles vs Permissions

Auth terminology is notoriously bad, so let’s first define what roles are vs what permissions are.

A role is just a user-facing label within an organization. Its purpose is to make it easier to explain what actions a user can take within the product (e.g. Bob’s the other Owner of our Github org, so he can update our billing information).

A permission represents something that user can do within the product. A user can have permission to invite people to the organization. A user can have permission to delete everything. Permissions themselves can be represented many different ways, but for this post, let’s say they are all strings like canViewBilling or billing::view or billing.

How do roles relate to permissions?

Each role will have a set of user permissions associated with it, like:

  • The Admin role has billing, issues::read , issues::edit, reports::read, reports::export permissions
  • The Member role has issues::read, issues::edit and reports::read permissions
  • The Contractor role just has the issues::read permission

When should you use roles vs permissions?

Typically, you should use both. A good rule of thumb is:

Roles are for your users. Permissions are for your developers/code.

What this means is that if you create an invitation flow where one user can invite another - they shouldn’t be individually picking each permission that user can do - they should just pick a role.

However, when you are writing code, it’s often easier to think about in terms of permissions rather than roles:

// Clear, concise, easily verifiable 
user.hasPermission("issues::edit")

// This is fine when you first start out, but is harder to keep up to
// date over a whole codebase over time as roles & permissions change
user.isRole("Admin")

Deciding on your Role Structure

No matter what you decide to do here, the most important thing is to keep it simple.

Github has 5 roles. Slack has 3 roles. Mercury has 3 roles.*

If they can get away with that level of simplicity, you likely can too.

*Now, technically all those products do have exceptions, because there will always be some customers where your role ⇒ permissions mapping doesn’t match what they want, but we’ll cover that later on.

Initially - keep it simple

When you are first starting out, the question to ask yourself is:

What is the simplest possible set of roles I can get away with?

It’s way more challenging to simplify your roles later on than it is to add new ones. Simplifying often means making decisions about what permissions users will lose. Start with something simple and only add more complexity when you need it.

A simple, hierarchical set of user roles like: Owner ⇒ Admin ⇒ Member can often be enough to cover most of the complexity of your application.

Can users have more than one role?

So far, when we’ve talked about Roles, it’s been in the “One role per user” context. A user is an Admin and that’s it.

For some products, it’s more natural to think of roles more like tags:

Some products lend themselves more to this approach, for example:

  • Products that have roles per function. For example, if you have a Developer role and a Designer role which have different responsibilities. A single user may need to be both a Developer and Designer. This is notably also a case where there isn’t as clear of a role hierarchy.
  • Products that have a few special one-off roles. For example, if you want to have a separate Billing role which you want to decouple from the rest of your roles. You see this a lot with products where the main users/adopters may not have the ability to pay for the product within their company.

Both approaches are totally reasonable - you should choose the one that resonates with your product best.

Common gotchas in RBAC as you grow

You’ve read up until this point and you are doing everything right. You have:

  • A simple set of 3 roles
  • Each role has a clear set of permissions associated with it
  • Your users use roles, but your code is written with permissions
  • Each user can only have one role within the organization

Everything’s going great until a coworker comes up to you and says:

“Hey so I know all Admins have the ability to export reports, but can we make it so only Admin’s on paid plans can do that?”

This is our first example where permissions alone can’t accurately tell us if a user does truly have the permission to do something.

Combining Permissions with Billing / Pricing Plans

All engineers know that adding more abstractions solve every problem. We can just create a new function called canExportReports which checks that both the user has the literal permission reports::export AND that the organization is on a paid plan.

function canExportReports(user) {
  return user.hasPermission("reports::export")
    && isOnPaidPlan(user.getOrgId())
}

This is fine, but we’re setting up a trap for future engineers. Someone using user.hasPermission("reports::export") over canExportReports may not realize the bug and it may even pass a code review.

And while there are other ways to really enforce the usage of canExportReports, I personally prefer a different approach:

Make it so the user only has that permission if they are on a paid plan.
Instead of having a mapping like:
Admins have reports::read and reports::export
Make it so your mapping is:
Admins on paid plans have reports::read and reports::export
Admins on free plans have reports::read

The best thing about this approach is that the developer experience of asking if a user can do a thing is always checking the permission. All other constraints should feed into whether they have that permission in the first place.

This approach can also work well for feature flagging, a/b testing, and more.

Large Customer: “We need more granularity!”

Your developer experience around permissions is great! Everyone’s loving it, until you get thrown a curveball.

A very large customer wants to use your product, but they have a bunch of requirements and alterations. You set up SAML and SCIM for them thinking that’s it but then they come in with the statement:

Your roles aren’t granular enough for us. We need a ReportOnly role, and a IssueOnly role, and a role specifically for Steve, and …

This is pretty common especially if you took the advice of keeping the role structure simple.

There are three common responses to this:

  1. “No, the product is what it is” - worth mentioning that this is an option, albeit maybe not one you are comfortable doing.
  2. You add those roles and make them available to everyone.
  3. You give the customer the ability to define their own roles and choose which permissions they have access to.

Unless the request seems very reasonable, you should avoid 2. One customer’s idea of what the “ReportOnly” role should be will differ from another.

3 on the other hand, is actually really simple to implement! All our code is already written in the form of user.hasPermission("reports::export") meaning that even if our customer creates 500 custom roles, our code stays exactly the same.

PropelAuth can do all of this for you

Forgive the interruption, but one of the main reasons we built PropelAuth was because building a B2B SaaS product is full of hidden gotcha’s around authentication and authorization.

It starts with just defining an “organization”, then invitation flows, then roles, but quickly you end up needing to build SAML, SCIM and all the awkward edge cases we described above.

We let you define your role structure (whether it’s one-role-per-user or multiple-roles-per-user, whether users can create their own roles or not) and then we take care of the rest.

Summary

Roles and permissions (RBAC) is the standard way B2B SaaS companies answer the question “can this user do this thing?”

You’ll want to make sure you:

  • Keep your roles as simple as possible
  • Expose roles to your users, but use permissions when you are writing code
  • Avoid cases where a user might have the permission to do X, but you need to check a few other things to make sure they can actually do X.
  • Be prepared for larger customers to want more control than you give everyone else.