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.
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 --> 403Design 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.
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
| Axis | Flat design | Tenant/module design |
|---|---|---|
| Best fit | Single operational context | Many tenants in one platform |
| Role shape | One role tier | System roles plus tenant-scoped roles |
| Scope | Injected request context | Tenant membership and DataAccess |
| Module access | Not needed | TenantModule is the final gate |
| Revocation | Session kill or per-request DB check | Per-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.