RBAC for SaaS: Roles & Permissions Schema and Enforcement

14 Jul 2026 · Bank K.

Design RBAC for multi-tenant SaaS — roles, permissions, a database schema, and where to enforce checks, with working code and clear tradeoffs.

Your SaaS started with one role: logged-in user. Then a customer asked for “an admin who can manage the team but a viewer who can’t change billing,” and now you need real authorization. RBAC — role-based access control — for SaaS is how you model who can do what, and getting the design right early saves a painful refactor later. This post covers a practical roles-and-permissions schema for a multi-tenant SaaS, where to actually enforce checks, and the tradeoffs to know.

RBAC in one paragraph

In RBAC, you don’t grant abilities to users directly. You define permissions (fine-grained actions like billing:read or member:invite), group them into roles (admin, member, viewer), and assign roles to users. A user’s effective permissions are the union of their roles’ permissions. The indirection is the whole point: change what admin can do once, and every admin updates.

For SaaS there’s a crucial extra dimension — tenancy. Most B2B SaaS is multi-tenant: users belong to organizations (teams, workspaces), and a role only means something within a tenant. Someone can be an admin of Org A and a viewer of Org B. Your model has to scope roles to tenants from day one, or you’ll be untangling it under pressure later.

The core schema

Here’s a schema that scales from your first team to thousands of tenants. The key decision is using a tenant column (org_id) on the membership, which is the approach that scales to tens of thousands of tenants in a single shared database — far simpler than per-tenant databases or schemas for an indie SaaS.

-- Tenants
CREATE TABLE organizations (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name        text NOT NULL,
  created_at  timestamptz DEFAULT now()
);

-- Users (global; a user can belong to many orgs)
CREATE TABLE users (
  id     uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email  text UNIQUE NOT NULL
);

-- Roles, scoped to an org so each tenant can have custom roles later
CREATE TABLE roles (
  id      uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  org_id  uuid REFERENCES organizations(id), -- NULL = system/built-in role
  name    text NOT NULL,
  UNIQUE (org_id, name)
);

-- Permissions: fine-grained actions
CREATE TABLE permissions (
  id    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  key   text UNIQUE NOT NULL  -- e.g. 'billing:read', 'member:invite'
);

-- Which permissions a role grants
CREATE TABLE role_permissions (
  role_id        uuid REFERENCES roles(id),
  permission_id  uuid REFERENCES permissions(id),
  PRIMARY KEY (role_id, permission_id)
);

-- Membership: a user's role WITHIN a specific org
CREATE TABLE memberships (
  user_id  uuid REFERENCES users(id),
  org_id   uuid REFERENCES organizations(id),
  role_id  uuid REFERENCES roles(id),
  PRIMARY KEY (user_id, org_id)
);

The memberships table is the heart of it: it ties a user to a role in the context of an org. That’s what makes a role tenant-scoped. A role never grants access outside its assigned tenant, even if another org has a role with the same name.

Resolving a user’s permissions

To check access, you resolve the user’s permissions for a specific org:

SELECT p.key
FROM memberships m
JOIN role_permissions rp ON rp.role_id = m.role_id
JOIN permissions p       ON p.id = rp.permission_id
WHERE m.user_id = $1 AND m.org_id = $2;

Cache this set per request (or per session) so you’re not re-querying on every check. A simple in-memory map keyed by ${userId}:${orgId} works fine for an MVP.

Where to enforce — and where not to

This is the part teams get wrong. RBAC that lives only in your UI is not access control — it’s a suggestion. Hiding a “Delete” button stops nobody who knows how to send an HTTP request. Real enforcement happens at the API/service layer, where every permission is checked consistently before any action runs.

A clean pattern is a middleware that loads the membership, attaches permissions to the request, and a guard you call in each handler:

// Middleware: resolve permissions for the current org
async function withAuthz(req, res, next) {
  const orgId = req.params.orgId;
  const membership = await getMembership(req.user.id, orgId);
  if (!membership) return res.status(403).json({ error: 'Not a member' });
  req.permissions = await getPermissionsForRole(membership.roleId);
  next();
}

function requirePermission(key) {
  return (req, res, next) => {
    if (!req.permissions.has(key)) {
      return res.status(403).json({ error: 'Forbidden' });
    }
    next();
  };
}

// Usage
app.delete(
  '/orgs/:orgId/members/:memberId',
  withAuthz,
  requirePermission('member:remove'),
  removeMemberHandler
);

Two non-negotiables:

  1. Always scope by tenant. Every query in a handler must filter by org_id, and the permission check must be evaluated for that org. The number-one multi-tenant bug is a valid permission check that then queries data from the wrong tenant. Defense in depth here is Postgres Row-Level Security: enforce org_id isolation at the database layer too, so an app-level mistake can’t leak cross-tenant data.
  2. Enforce on the server, every time. Use the UI permissions to hide controls for UX, but never to authorize. The server check is the real gate.

Don’t want to build the membership and permission plumbing? Beag gives micro-SaaS authentication with users and accounts wired up, alongside Stripe payments — so you can layer roles on a solid foundation instead of building auth, billing, and authz from scratch.


Built-in vs custom roles

Start with a small set of built-in roles (owner, admin, member, viewer) that cover 95% of customers. Resist the urge to let every tenant invent roles on day one — it’s a feature you can add later when an enterprise customer actually needs it.

When you do add custom roles, compose them hierarchically rather than duplicating: let a manager role extend member and add a few supervisory permissions, reflecting the NIST RBAC role-hierarchy idea. And add a simple “compare roles” view in your admin UI — it prevents tenants from creating five near-identical roles that nobody can tell apart, which is a real source of RBAC rot in production.

RBAC vs ABAC: don’t over-engineer

RBAC answers “what role is this user?” Attribute-based access control (ABAC) answers richer questions like “can this user edit this specific document during business hours?” ABAC is more flexible but far more complex to build and reason about.

For most indie SaaS, RBAC is the right altitude. Tenant-scoped roles with a clear permission list cover the vast majority of real requirements. Reach for ABAC or per-resource sharing (like Google Docs–style “share this one item”) only when a concrete customer need forces it — not preemptively.

Tradeoffs to keep in mind

  • Permission granularity. Too coarse (admin can do everything) and you can’t satisfy “viewer who can see billing but not change it.” Too fine (a permission per button) and your role management becomes unusable. Aim for resource-level verbs: billing:read, billing:write, member:invite, member:remove.
  • Performance. Resolving permissions per request adds a query. Cache aggressively; permissions change rarely.
  • Enforcement consistency. The whole model is worthless if one endpoint forgets to check. Centralize the guard so checks are boring and uniform — boring enforcement is correct enforcement.

Wrapping up

A solid SaaS RBAC setup is: tenant-scoped roles in a memberships table, fine-grained permissions grouped into roles, resolution cached per request, and enforcement at the API/service layer (never the UI), backed by tenant scoping and ideally Postgres RLS. Start with built-in roles, add custom and hierarchical roles only when needed, and skip ABAC until a real requirement demands it.

Want auth and billing handled so you can focus on the authorization that’s actually unique to your product? Beag adds authentication and Stripe payments to your MVP in minutes →. For the layer underneath, see our JWT vs session auth guide.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →