Engineering Notes
Engineering PatternsMar 2026 · 14 min read

RBAC That Survives Real Tenants

What I learned comparing flat permission catalogs, per-request scope injection, tenant roles, module gates, and immediate revocation.

RBACPostgreSQLMulti-Tenancy

Summary

RBAC becomes interesting when the product gains tenants, modules, revocation rules, and data scope. The core lesson from my office projects: keep permission resolution grounded in the database, make tenant boundaries explicit, and treat module access as a final gate instead of duplicating role data.

What I learned comparing flat permission catalogs, per-request scope injection, tenant roles, module gates, and immediate revocation. In this post, I'll walk through the key concepts with code examples drawn from real production implementations.

The Real Problem

I worked on production ERP-style systems where access control was not just a list of permissions. The hard parts were row-level scope, tenant isolation, module subscriptions, soft-delete reactivation, and immediate revocation when an admin changes a role. A token carrying stale permissions would have been convenient, but it would also be wrong.

Design 1: Flat Roles with Scope Injection

The simpler system used a flat User -> Role -> Permission graph. The middleware resolved active roles and permissions per request, then injected a scope object into the context so handlers could filter rows without rebuilding authorization logic.

Flow· Flat RBAC request path
HTTP request
  |
  v
JWT middleware extracts user_id
  |
  v
RequirePermission("resource.action")
  |
  v
Load active user roles and permissions
  |
  v
Build map[permission]struct{} for O(1) checks
  |
  v
Inject ScopeContext {scope_id, department_ids, data_access}
  |
  +-- permission found --> handler runs with row filters
  |
  +-- missing permission --> 403

Design 2: Tenant Roles with Module Gates

The larger system needed the same user account to hold different roles in different tenants. The permission check therefore moved through tenant membership, tenant-scoped roles, system roles, and a final TenantModule gate. A role could grant a permission, but the tenant still had to have the module enabled.

Flow· Tenant-aware permission path
checkUserPermission(user, tenant, code)
  |
  +-- is_superadmin? --> allow
  |
  v
Resolve tenant from request, JWT, or active membership
  |
  v
DataAccess == single_tenant?
  |
  +-- yes --> load TenantMember -> TenantMemberRole -> RolePermission
  |
  +-- no  --> load system UserRole -> RolePermission
  |
  v
Permission code found?
  |
  +-- no --> deny
  |
  v
TenantModule for permission.module enabled?
  |
  +-- yes --> allow
  +-- no  --> deny
AxisFlat designTenant/module design
Best fitSingle operational contextMany tenants in one platform
Role shapeOne role tierSystem roles plus tenant-scoped roles
ScopeInjected request contextTenant membership and DataAccess
Module accessNot neededTenantModule is the final gate
RevocationSession kill or per-request DB checkPer-request DB resolution, no permission cache

Why I Avoided Permission Claims in JWT

A JWT full of permissions looks fast until someone revokes access. Then every live token becomes a stale authorization artifact. For these systems I kept tokens focused on identity and resolved permissions on each protected request. The cost is a few joins; the benefit is that the database remains the source of truth.

Recruiter signal

This is the kind of backend detail I want visible: not just 'implemented RBAC', but the tradeoff between speed, revocation correctness, tenant boundaries, and product-level module gating.

Rules I Carry Forward

  • Tokens should identify the user; the database should decide what the user can do now.
  • Soft-deleted join rows are useful when revoke-and-regrant must not fight unique indexes.
  • Tenant membership and permission grants are separate concerns; mixing them creates confused boundaries.
  • Module subscriptions should gate permissions at check time, not duplicate permissions across every tenant role.
  • The simplest RBAC model is the right one until tenant isolation, module access, or revocation pressure forces a richer design.
Share