Blog
Explainer

What is RBAC, and where it leaks in a B2B SaaS app

RBAC grants permissions to roles and roles to people. In a B2B app the role sits on the workspace membership, and the leaks are in the defaults, not the matrix.

Dharmendra Jagodana8 min read

In short

RBAC, role-based access control, decides what someone may do by the role they hold rather than who they are: permissions attach to roles, and people are given roles. In a B2B SaaS app the role belongs to the workspace membership, so one person can be an admin in one workspace and a viewer in the next.

RBAC, role-based access control, decides what a person may do by the role they hold instead of by who they are. Permissions attach to roles, people are given roles, and every check asks one question: does this person's role grant this action?

That is the textbook answer and it is correct. It was also written for a company deciding what its own employees can open. A B2B SaaS app is shaped differently. The person asking is a member of a customer's workspace, often of more than one, and the bugs that matter are rarely in the role table.

Why roles exist

Without roles you grant permissions to people one at a time. Fine for five people. Then someone changes jobs and you have to remember which switches they had. A role is an indirection: edit it once, and everyone holding it changes with it.

In a SaaS product there is a second reason, and it is the bigger one. Your customer's admin has to decide who on their team may invite people or touch billing, without filing a ticket with you. RBAC is how you hand them that decision. You ship the permissions and the default roles; who holds which role is their call, made on a settings screen you will probably never look at.

How it works when there are tenants

Three pieces. Permissions are the actions your API enforces, like inviting a member or managing billing. Roles are named bundles of permissions. Assignments say which role a person holds.

The assignment is where multi-tenant RBAC parts ways with every textbook diagram you have seen, because it does not sit on the user. It sits on the membership, the row that says this person belongs to this workspace. In BuildBase that row lives in the workspaces_users collection and carries the workspace, the user and the role. Same person, two rows, two roles: admin in the workspace they made, viewer in the client workspace someone invited them to. Switch workspace and the answer to every check changes with it.

Key takeaway

Never ask "is this user an admin?" Ask "is this user an admin in this workspace?" The role is a property of the membership, so a check with no workspace in it is checking the wrong thing.

Here is what ships by default. Every org starts with three workspace roles and a matrix of 12 platform permissions:

RoleHoldsIn practice
adminall 12Settings, members, billing, usage. Everything but deleting the workspace
editor6Views settings, members, features, billing and usage, and records usage
viewer2Views settings and members

There is no owner role. Whoever creates a workspace is made its admin, passes every permission check as its creator, and is the only one who can delete it. That is worth copying. Ownership is a fact about who created something, not a bundle of permissions. Make it a role and an admin can hand it to someone else, or take it away from the person who set the workspace up.

You can add to it from the console: roles of your own, and permissions of your own next to ours (app:reports:export is the shape we suggest). A new custom role starts with editor's permissions until someone sets it. And each workspace can loosen or tighten the matrix for itself, so it resolves in layers: the workspace's override, then your org-wide matrix, then the built-in defaults. For each role, the first layer that mentions it decides.

There is a second tier above all this. An org role (owner, admin, user, or one the customer defines) says who someone is across your whole app, and a workspace role says what they may do inside one team. They even run on different clocks. The org role is copied onto the session and re-read hourly, but changing it through the console, or blocking the user, deletes their sessions on the spot, so the new role applies at their next sign-in. The hour only catches role writes that skip that route. A guarded workspace route reads the membership row every time it is called.

Where the check runs

On the server. Hiding a button is a courtesy, not access control.

import { Permission, usePermissions } from '@buildbase/sdk/react';

function MembersHeader() {
  const { can } = usePermissions();
  return can(Permission.WORKSPACE_MEMBERS_INVITE) ? <InviteButton /> : null;
}

That decides what to render, and it earns its place: a viewer should not be shown a button that is going to fail. What actually stops a viewer from inviting someone is the API answering 403, including when they call it with curl and never load your UI.

The quieter failure is the client and the server disagreeing about what a role means. We had it. editor had no entry in the default permission map, so the server reached a fallback list while the SDK, which had no fallback, denied editors everything. Editors were using an app that refused them things the API would have let them do. From the outside a bug like that never looks like RBAC. It looks like a broken product.

The missing entry is there now, but the fix that matters is structural. One shared function works out what a role holds, and the server enforces with it and hands the SDK its result, so the screen and the API cannot disagree. Your own backend can ask the same question rather than re-implementing the rules: GET /api/v1/public/workspaces/:workspaceId/permissions/me returns the caller's role and the permissions the server resolved for them.

Where RBAC actually leaks

Everyone reviews the matrix. The leaks are in two places nobody draws a column for.

The role someone gets when nobody chose one. An invitation whose role was deleted before it was accepted. A role name the SDK does not recognise. Something fills that gap, and whatever fills it is the most-assigned role in your system without anyone deciding so. Ours was admin for new orgs, which meant anyone who joined a workspace that way could change its settings, remove its members and manage its billing.

The default is viewer now, and admin cannot be the default at all. An org can pick a different default role, but if it picks admin, the platform reads it as viewer. Admin is only ever given on purpose.

The route that checks membership and nothing else. "Is this person in the workspace?" feels like a permission check. It isn't one. Our usage routes, the ones that add to a workspace's metered usage and spend its credits, carried a rate limiter and a membership check, and nothing more. So a viewer could not see the workspace's usage but could add to it. And spend its credits.

The fix was a permission that did not exist, workspace:usage:record. Editors hold it and viewers do not. It is deliberately separate from workspace:billing:manage, because an ordinary member of your customer's team needs to use what the workspace pays for and must not be able to change the subscription.

Which is the general lesson. A matrix can be perfect and still protect nothing on a route that never consults it. Every new endpoint needs a named permission, and "they're a member" does not count as one.

When RBAC is the wrong model

RBAC answers "what can someone in this position do". It is the wrong tool when the honest answer depends on something other than position.

When access follows the object. "Share this one document with this contractor" is not a role. Neither is "everyone on the project sees the project's files". That is relationship-based access, ReBAC, or a plain access list per object, and forcing it into roles gets you a role per document. This is the case where ReBAC wins outright.

When access depends on attributes. "Reps can edit deals in their own region." "Nobody exports outside business hours." These read properties of the person, the record or the request, which is attribute-based access, ABAC. You can fake them with roles: Editor EMEA, Editor US, Editor APAC, and next quarter Editor LATAM. That is the role explosion every explainer warns about, and the cause is always the same. A role is being asked to carry data.

When there is no team yet. If every workspace in your product holds exactly one person, a role system is ceremony. What you need first is the workspace, not the user, owning the data, so roles have somewhere to attach when the second person shows up.

Our own position, plainly: BuildBase's permissions are RBAC, at the org level and the workspace level, with custom roles and custom permissions. We do not do ReBAC, ABAC or policy-as-code. If your product's core is per-document sharing, you need something else for that part, and roles for the rest.

Roles are not entitlements

A role answers "may this person do this". An entitlement answers "did this workspace pay for it". Different owners, too: your customer's admin assigns roles, and your pricing page decides entitlements.

So a viewer on your top plan still cannot manage billing, and an admin on your free plan still cannot use a feature the plan does not include. Both checks run and neither stands in for the other. The plan side of that split, and why it should never be a flag, is entitlements are not feature flags.

If you are building this on BuildBase, the permissions module is where it lives, and it is what PlugNode and AgentCenter, two of our own products, use for their roles. The reference has the components. The two leaks above are the same whatever you build on.

rbac
permissions
multi-tenancy
workspaces

Frequently Asked Questions

Can a user have more than one role?

Yes. In a B2B SaaS app the usual shape is one role per workspace membership, so the same person can be an admin in the workspace they created and a viewer in a client workspace they were invited to. The role changes when they switch workspace. BuildBase stores one role per membership, plus a separate org-level role for the app as a whole.

What is the difference between RBAC and ABAC?

RBAC decides from the role a person holds. ABAC, attribute-based access control, decides from properties of the person, the record and the request, such as region, record owner or time of day. RBAC is easier to read and audit. ABAC handles rules that would otherwise need a separate role for every combination.

What is an RBAC matrix?

A table with permissions as rows and roles as columns, where each cell says whether that role grants that permission. In a multi-tenant app it often comes in layers. BuildBase has three: the built-in defaults, an org-wide matrix you edit, and per-workspace overrides on top. For each role, the most specific layer that mentions it decides.

What is the difference between role-based and rule-based access control?

Role-based access decides from who the person is inside the organization. Rule-based access decides from conditions that apply to everyone, such as an IP allowlist or business hours. Most real systems run both, and a rule can refuse a request that the role on its own would allow.

Put this into practice

The modules in this post are one console away. 7-day free trial, no credit card.

7-day free trialNo credit card requiredCancel anytime