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.
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:
| Role | Holds | In practice |
|---|---|---|
admin | all 12 | Settings, members, billing, usage. Everything but deleting the workspace |
editor | 6 | Views settings, members, features, billing and usage, and records usage |
viewer | 2 | Views 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.