TL;DR — Authorization in a multi-tenant SaaS is really two questions, and most breaches come from answering only the first: “can this role do this action?” (RBAC) and “does this record belong to this user’s tenant?” (isolation). You don’t need OPA, Zanzibar, or a DSL to answer them. Model permissions (not roles) as the thing you check, map roles→permissions in a table, gate actions with a requirePermission middleware, and — the part people forget — scope every query by tenant_id on the server, derived from the session, never from the request. Fine-grained, auditable, least-privilege access control in plain SQL.

The two questions, and the one everyone forgets

Picture the platform: many organizations (tenants), each with owners, managers, and staff, all hitting the same API and the same database. A manager at Org A opens GET /orders/9182. Two separate things must be true for that to be allowed:

  1. Action permission: does a manager have the right to read orders at all?
  2. Tenant ownership: does order 9182 belong to Org A?

RBAC — roles and permissions — answers only #1. And a system that checks only #1 is one guessed id away from a cross-tenant data leak: a manager at Org A has the “read orders” permission, so the check passes, and they read Org B’s order 9182. Broken access control (OWASP’s #1 risk) is almost always this — a present permission check and a missing ownership check. Hold both questions in your head the entire way through; they never collapse into one.

Model permissions, not roles

The classic mistake is scattering if (user.role === 'manager') across the codebase. Roles are a packaging of capabilities, not the capability itself — the day you add a “shift supervisor” who can do most-but-not-all of what a manager can, every hard-coded role check becomes a lie you have to hunt down.

So the thing you check is always a permission (a verb on a resource); roles are just named bundles of permissions:

  user ──has──▶ role ──grants──▶ [ permissions ]         check THIS
                                   orders:read
   'manager' ─────────────────▶   orders:write     ◀──── requirePermission('orders:write')
                                   menu:read
-- Permissions are the atoms. Roles bundle them. Users have a role per tenant.
CREATE TABLE permissions      (key TEXT PRIMARY KEY);               -- 'orders:read', 'menu:write'
CREATE TABLE role_permissions (role TEXT, permission_key TEXT REFERENCES permissions(key));
CREATE TABLE memberships      (user_id UUID, tenant_id UUID, role TEXT,
                               PRIMARY KEY (user_id, tenant_id));   -- a user's role IS per-tenant

Note memberships: a user’s role is scoped to a tenant. The same person can be an owner of their own org and read-only staff in a partner org — because the role lives on the membership, not on the user. This one table shape prevents a whole category of “why does this user have admin everywhere” bugs.

Check #1: the permission middleware

Resolve the user’s permissions for the active tenant once per request, then gate each action declaratively. No role names in business logic — ever:

// Resolve permissions for THIS user in THIS tenant (cache per request).
async function loadPermissions(userId, tenantId) {
  const { role } = await db.memberships.find({ userId, tenantId }) ?? {};
  if (!role) return new Set();                       // not a member → zero permissions
  return new Set(await db.rolePermissions.keysFor(role));
}

// Declarative gate on each route.
const requirePermission = (perm) => (req, res, next) => {
  if (!req.auth.permissions.has(perm)) {
    return res.status(403).json({ error: 'forbidden' });   // 403: authenticated, not allowed
  }
  next();
};

router.get('/orders',        requirePermission('orders:read'),  listOrders);
router.post('/orders/:id/refund', requirePermission('orders:refund'), refund);

Adding a role is now a few rows in role_permissions, not a code change. Auditing “who can issue refunds” is one query: SELECT role FROM role_permissions WHERE permission_key = 'orders:refund'. That queryability is the quiet superpower of keeping permissions in data instead of in if statements.

Check #2: tenant scoping — the load-bearing one

Here’s the part that actually stops breaches, and the part a policy engine wouldn’t do for you anyway. Every query touching tenant data must be filtered by the tenant id from the session — never from the request.

// ❌ tenant id from the request → user picks their own tenant → cross-tenant read
const order = await db.orders.find({ id: req.params.id, tenant_id: req.query.tenantId });

// ✅ tenant id from the verified session → user CANNOT escape their tenant
const order = await db.orders.find({ id: req.params.id, tenant_id: req.auth.tenantId });
if (!order) return res.status(404).json({ error: 'not found' });  // 404, not 403 — see below

The tenant_id comes from the authenticated session (req.auth.tenantId), established at login and never overridable by a query param, header, or body field. Get this wrong once — trust a client-supplied tenant id in a single endpoint — and RBAC becomes theater, because the attacker just points a legitimately-permitted action at someone else’s data. This is the exact server-authoritative discipline behind making the server own order creation and budget enforcement in the subsidy engine: the number that decides access is computed server-side, every time.

A subtle but important choice above: return 404, not 403, for a record outside your tenant. A 403 confirms “this id exists, you just can’t see it” — an enumeration oracle. 404 leaks nothing about what exists in other tenants.

For defense in depth, push tenant scoping below the application too — Postgres Row-Level Security enforces tenant_id = current_setting('app.tenant_id') at the database, so a forgotten WHERE clause in one query can’t leak across tenants. App-layer scoping is your primary control; RLS is the seatbelt for the day someone writes a raw query and forgets.

Ownership beyond the tenant: resource-level checks

Sometimes “same tenant” isn’t fine-grained enough — staff should see only orders for their assigned outlet, not the whole org. That’s still not a policy engine; it’s one more predicate derived from the membership:

// Manager: all outlets in tenant. Staff: only assigned outlets.
function outletFilter(auth) {
  return auth.permissions.has('orders:read_all_outlets')
    ? { tenant_id: auth.tenantId }
    : { tenant_id: auth.tenantId, outlet_id: { in: auth.assignedOutletIds } };
}
const orders = await db.orders.findMany(outletFilter(req.auth));

Fine-grained access is just more specific filters computed from verified identity. You can go a surprisingly long way — tenant, role, outlet, ownership — before a relational schema stops being enough.

When you actually do want a policy engine

I’m not saying policy engines are pointless — I’m saying they’re rarely the first tool. Reach for OPA/Cedar or a Zanzibar-style system (SpiceDB) when you genuinely have:

  • Deeply nested, arbitrary relationships — “users who can edit a doc because they’re in a group that was granted access to a folder three levels up.” That relationship graph is exactly what Zanzibar exists for, and modeling it in SQL joins gets ugly fast.
  • Cross-service authorization where many services must evaluate the same policy consistently, and you want it externalized as one source of truth.
  • Policy authored by non-engineers or that changes without deploys, needing a real rules language and its tooling.

A single multi-tenant app with roles, tenants, and outlet scoping is none of those. Adding a policy engine there buys you a network hop, a second system to run, and a DSL to learn — to replace a Set.has() and a WHERE tenant_id = $1.

What actually broke in production

  • A new endpoint trusted a tenantId from the request body. Ninety-nine routes derived it from the session; one didn’t, and it was invisible until an audit. The fix was structural: tenantId is only ever read from req.auth, and a lint rule now flags any query where tenant_id traces back to req.body/req.query. One inconsistent endpoint is all it takes.
  • A role check leaked as a UI-only gate. A “refund” button was hidden for staff on the frontend, but the endpoint itself wasn’t gated — so a crafted request refunded anyway. Frontend hiding is UX; the permission check has to live on the server, always. (Same lesson as never trusting the client.)
  • 404-vs-403 leaked existence. An early version returned 403 for other-tenant records, which let someone enumerate valid order ids across the platform. Switched to 404 for anything outside the caller’s tenant.

What I’d still improve

  • Row-Level Security everywhere, not just the sensitive tables. It’s on the highest-risk tables; I’d make it the default for every tenant-scoped table so app-layer scoping is genuinely belt-and-suspenders.
  • A permission catalog as code. Right now permissions are rows; I’d generate them from a single declarative manifest so the set is reviewable in a PR and impossible to typo (orders:refund vs order:refund silently granting nothing).
  • Per-request permission caching across services. As the system splits, re-resolving permissions per hop adds latency; a short-lived signed claim of “permissions for this tenant” would cut it without reaching for a central engine.

FAQ

Do I need OPA or Zanzibar for multi-tenant authorization? Usually not first. A single application with roles, tenants and resource scoping is answered by a permission set and a WHERE tenant_id = $1. Policy engines earn their cost when relationships are deeply nested, when several services must evaluate identical policy, or when non-engineers author it.

What are the two checks every authorization needs? Can this role perform this action, and does this record belong to this caller’s tenant. RBAC answers only the first. A system that checks permission without checking ownership is one guessed identifier away from a cross-tenant leak, which is what most broken-access-control findings actually are.

Should I check roles or permissions in application code? Permissions. Roles are a bundle of capabilities, not a capability, so a hard-coded role check becomes wrong the moment you add a role that can do most-but-not-all of what another can. Checking permissions also makes “who can issue refunds” a single query instead of a code search.

Where should the tenant id come from? The authenticated session, established at login, never from a query parameter, header or request body. A client-supplied tenant id turns every correctly-permitted action into a cross-tenant read. One endpoint getting this wrong is enough to undo the whole model.

Should an out-of-tenant record return 403 or 404? 404. A 403 confirms the record exists and you merely cannot see it, which is an enumeration oracle across tenants. Returning 404 for anything outside the caller’s scope reveals nothing about what exists elsewhere.

Is Row-Level Security a replacement for application-layer scoping? No — it is the seatbelt. Application scoping stays the primary control because it carries the request context. RLS catches the day someone writes a raw query and forgets the tenant filter, which is exactly the failure that application discipline cannot prevent on its own.


The one idea to take away

Fine-grained authorization in a multi-tenant SaaS is two checks, not one: can this role do this action (a permission set) and does this record belong to this tenant (a session-derived tenant_id filter, enforced server-side and ideally by RLS too). Model permissions as data, never hard-code roles, and never let the client tell you which tenant it is. Do that and you have least-privilege, auditable, fine-grained access control — in plain SQL, with no policy engine to operate until the day your relationships genuinely outgrow a WHERE clause.