Building a Modular RBAC Permission System with NodeJS

5/3/2025Cybersecurity & Quality Assurance14 min read
Featured image for article: Building a Modular RBAC Permission System with NodeJS

As modern SaaS and enterprise applications grow, so does the complexity of handling user permissions — especially in multi-tenant architectures where each customer (tenant) may have its own admins, members, and unique access policies. A structured permission system is no longer a luxury but a necessity. Without clear rules, it's easy to introduce security holes (users accessing data they shouldn’t) or create maintenance nightmares (scattered if checks all over the codebase).

To tackle this, developers often turn to Role-Based Access Control (RBAC). RBAC lets you assign users roles (like Admin, Editor, Viewer) and define what each role can do. This abstracts away repetitive permission checks into a central system. In multi-tenant apps, we typically scope those roles per tenant: a user might be an Admin in Tenant A (full access) but a Member in Tenant B (limited access). By modularizing the logic, our code remains clean and our security rules stay consistent and testable.

In the sections below, we’ll walk through a modular RBAC system built with Prisma and Node.js. We’ll see how a central PermissionService delegates checks to domain-specific services (UserPermissionService and TenantPermissionService) and how static role definitions keep the rules transparent. Code snippets illustrate the design, and by the end you’ll understand how to build a clean, maintainable permission layer in your backend.

Architecture Overview

At a high level, our permission system centers around three main components:

  • PermissionService: The central orchestrator. Every time we need to check if an action is allowed, we ask this service. It figures out who (or what) is requesting, what they want to do, and on which model.

  • UserPermissionService: A domain-specific service handling permissions for user-related actions. It encodes rules like “a Tenant Admin can update another user” or “a regular Member can only read certain fields”.

  • TenantPermissionService: Another domain-specific service for tenant-level actions. This covers roles like Tenant Owner or Tenant Admin, defining what each can do at the scope of the tenant itself or related resources.

In practice, the flow looks like this:

  • We call PermissionService.hasPermission(actorId, action, model).

  • The PermissionService first identifies what type of entity actorId is (a User or a Tenant, see next section).

  • It then delegates the permission check to either UserPermissionService or TenantPermissionService.

  • These services consult static role-permission maps (hard-coded or defined in code) to see if the requested action on the model is allowed for the actor’s role.

  • The result bubbles up to whoever called hasPermission, and we either proceed or block access.

This layered design keeps things modular. For example, if you later add a new resource model (say, Project), you only update the relevant permission maps or logic in one place, without scattering if (role === 'Admin') throughout your controllers. The PermissionService acts as a single point of entry, while the domain services encapsulate the nitty-gritty of who can do what.

Determining the Model Type

A key trick in PermissionService is figuring out what type of entity is trying to perform the action. We might receive a numeric actorId, but that ID could belong to a User or to a Tenant. The permission logic differs depending on whether “Alice” (a user) is asking to do something, or whether the “Finance Department” (a tenant) is doing something.

In a typical Prisma setup, we can detect the type by querying the database. For example:

async hasPermission(actorId: number, action: string, model: string): Promise<boolean> {
  // Try to load a User with this ID
  const user = await this.prisma.user.findUnique({ where: { id: actorId } });
  if (user) {
    // It's a user asking. Delegate to UserPermissionService.
    return this.userPermissionService.hasPermission(user, action, model);
  }
  // If not a user, try Tenant
  const tenant = await this.prisma.tenant.findUnique({ where: { id: actorId } });
  if (tenant) {
    return this.tenantPermissionService.hasPermission(tenant, action, model);
  }
  // If it's neither, deny by default
  return false;
}

In this example, we attempt to find a User record by the given actorId. If found, we know the requester is a user; otherwise we try to find a Tenant with that ID. This lookup tells us which domain-specific service to use. (In a more optimized setup, you might tag the request context with user vs. tenant ahead of time, but the principle is the same: distinguish actors by their identity.)

Permission Checking Logic

Once we know who (a user or tenant) is asking, we check what they want to do. We typically break this down by action (like 'create', 'read', 'update', 'delete') and model (like 'User', 'Tenant', 'Project', etc.).

For example, suppose User 123 (with a certain role) wants to read the User model (perhaps listing all users in a tenant). Our flow is:

  1. Call PermissionService.hasPermission(123, 'read', 'User').

  2. We identify that 123 is a User, so we call UserPermissionService.hasPermission(userObject, 'read', 'User').

  3. Inside UserPermissionService, we look up the user’s role and see what that role is allowed to do on the User model.

The hasPermission method in each service typically works like:

hasPermission(actor, action: string, model: string): boolean {
  const role = actor.role;                      // e.g. 'ADMIN' or 'MEMBER'
  const modelPerms = RolePermissions[role]?.[model];
  return modelPerms ? modelPerms.includes(action) : false;
}

Here, RolePermissions is a static map (in our code, a property of the service) that lists which actions each role can perform on each model. For instance, RolePermissions['ADMIN']['User'] = ['create', 'read', 'update', 'delete']. The method returns true if the action is listed for that role/model, otherwise false.

This distinction between action and model ensures fine-grained control. You might allow a user role to read the Project model but not create it, or allow a tenant role to delete the Tenant (for account deletion) but not allow normal members that power.

Delegation to Domain-Specific Services

The clear separation in our design is that user-related rules live in UserPermissionService and tenant-related rules live in TenantPermissionService.

  • UserPermissionService handles permissions like managing user accounts within a tenant. For example, a tenant-level Admin might be able to invite or remove other users, while a standard Member can only update their own profile. The code would check the user’s role (maybe ADMIN, MEMBER, GUEST etc.) and see what is allowed on the User model (or other related models, if any).

  • TenantPermissionService covers actions at the scope of the tenant itself or its global resources. For instance, only a Tenant Owner might be able to update tenant settings or delete the tenant, while a Tenant Admin can manage projects or billing info. This service also checks the actor’s role (like OWNER, ADMIN, MEMBER) against its static rules for the Tenant model or others (such as Project, Billing, etc. if applicable).

In code, PermissionService simply decides which service to call:

if (user) {
  // Delegate user-based check
  return this.userPermissionService.hasPermission(user, action, model);
} else if (tenant) {
  // Delegate tenant-based check
  return this.tenantPermissionService.hasPermission(tenant, action, model);
}

This keeps each service focused on its domain. If you add new user-specific rules (say, “Guests can only view other users, but not modify”), you edit UserPermissionService alone. If you add a new tenant-level role or model (say, a Project model only manageable by certain tenant roles), it goes in TenantPermissionService. The central permission logic doesn’t need to change at all.

Role Definitions

At the heart of each permission service lies the static role-to-permissions map. This is usually a simple object (or Record) that declares, for each role, which actions are allowed on which models. This might be hard-coded, or it could be generated from configuration, but conceptually it looks like this:

enum UserRole  { ADMIN = 'ADMIN', MEMBER = 'MEMBER', GUEST = 'GUEST' }
enum TenantRole{ OWNER = 'OWNER', ADMIN = 'ADMIN', MEMBER = 'MEMBER' }

export class UserPermissionService {
  // Define permissions for user-related roles
  private static rolePermissions: Record<UserRole, Record<string, string[]>> = {
    [UserRole.ADMIN]: {
      'User':   ['create', 'read', 'update', 'delete'], 
      // admin might also act on tenant if needed, e.g. 'Tenant': ['read']
    },
    [UserRole.MEMBER]: {
      'User':   ['read', 'update'],      // can read and update users
    },
    [UserRole.GUEST]: {
      'User':   ['read'],               // only read
    },
  };

  hasPermission(user: { role: UserRole }, action: string, model: string): boolean {
    const perms = UserPermissionService.rolePermissions[user.role];
    const allowedActions = perms ? perms[model] : null;
    return allowedActions ? allowedActions.includes(action) : false;
  }
}

export class TenantPermissionService {
  // Define permissions for tenant-related roles
  private static rolePermissions: Record<TenantRole, Record<string, string[]>> = {
    [TenantRole.OWNER]: {
      'Tenant': ['create', 'read', 'update', 'delete'], 
      'Project': ['create','read','update','delete'],
      'User': ['invite','remove']   // owner can invite or remove users, for example
    },
    [TenantRole.ADMIN]: {
      'Tenant': ['read', 'update'],
      'Project': ['create','read','update','delete'],
      'User': ['invite']
    },
    [TenantRole.MEMBER]: {
      'Tenant': ['read'],
      'Project': ['read']
    },
  };

  hasPermission(tenant: { role: TenantRole }, action: string, model: string): boolean {
    const perms = TenantPermissionService.rolePermissions[tenant.role];
    const allowedActions = perms ? perms[model] : null;
    return allowedActions ? allowedActions.includes(action) : false;
  }
}

In these examples, rolePermissions is a nested map: the first key is the role, the second key is the model name. The array lists allowed actions. When checking a permission, we simply do a lookup to see if the action is in the array. If not defined or not included, the permission is denied.

By keeping these definitions static and centralized, we ensure that any developer or auditor can quickly see “what can an Admin do?” in one place. If we ever need to change the policy (say, allow a Member to delete their own account), we update the static map and it immediately applies everywhere.

Executing with Permission Awareness

Beyond checking permissions, our system can also wrap execution of actions to automatically enforce checks. For example, we might provide a helper method in PermissionService like:

async execute(actorId: number, action: string, model: string, callback: () => any, fallback?: () => any) {
  if (await this.hasPermission(actorId, action, model)) {
    return callback();
  } else {
    console.warn(`Permission denied: Actor ${actorId} cannot ${action} ${model}`);
    return fallback ? fallback() : null;
  }
}

This execute method takes an actorId, the desired action and model, and two callbacks: a callback to run if access is allowed, and an optional fallback if not. It first calls hasPermission. If it returns true, it invokes callback() (for example, performing the database update). If false, it logs a warning and runs the fallback (perhaps an error or no-op).

This pattern keeps the permission check close to the business logic with minimal boilerplate. Instead of writing:

if (await permissionService.hasPermission(userId, 'update', 'Project')) {
  // perform update
} else {
  throw new Error("Forbidden");
}

You do:

await permissionService.execute(userId, 'update', 'Project', async () => {
  // perform update
}, () => { throw new Error("Forbidden"); });

It’s a small difference in code, but it centralizes logging and error handling for unauthorized cases. Every denied action gets a consistent warning, and you don’t accidentally forget a check or log.

Code Snippets

Below are key excerpts from each class to illustrate the implementation. These snippets assume you have a PrismaClient instance available for database access, and simple User/Tenant types with id and role fields. Adjust names to fit your schema.

PermissionService

class PermissionService {
  private userPermissionService: UserPermissionService;
  private tenantPermissionService: TenantPermissionService;

  constructor(private prisma: PrismaClient) {
    this.userPermissionService = new UserPermissionService(prisma);
    this.tenantPermissionService = new TenantPermissionService(prisma);
  }

  async hasPermission(actorId: number, action: string, model: string): Promise<boolean> {
    // Determine if actorId refers to a User
    const user = await this.prisma.user.findUnique({ where: { id: actorId } });
    if (user) {
      // Delegate to UserPermissionService
      return this.userPermissionService.hasPermission(user, action, model);
    }

    // Otherwise, check if it's a Tenant
    const tenant = await this.prisma.tenant.findUnique({ where: { id: actorId } });
    if (tenant) {
      // Delegate to TenantPermissionService
      return this.tenantPermissionService.hasPermission(tenant, action, model);
    }

    // If not found as User or Tenant, deny by default
    return false;
  }

  async execute(actorId: number, action: string, model: string, callback: () => any, fallback?: () => any) {
    if (await this.hasPermission(actorId, action, model)) {
      return callback();
    } else {
      console.warn(`Permission denied: Actor ${actorId} cannot ${action} ${model}`);
      return fallback ? fallback() : null;
    }
  }
}

UserPermissionService

enum UserRole { ADMIN = 'ADMIN', MEMBER = 'MEMBER', GUEST = 'GUEST' }

export class UserPermissionService {
  // Define what each user role can do on each model
  private static rolePermissions: Record<UserRole, Record<string, string[]>> = {
    [UserRole.ADMIN]: {
      'User':   ['create', 'read', 'update', 'delete'], 
      // ... you can add other model permissions if needed
    },
    [UserRole.MEMBER]: {
      'User':   ['read', 'update'],  // can view and update users
    },
    [UserRole.GUEST]: {
      'User':   ['read'],           // only view
    },
  };

  constructor(private prisma: PrismaClient) {}

  hasPermission(user: { role: UserRole }, action: string, model: string): boolean {
    const perms = UserPermissionService.rolePermissions[user.role];
    const allowed = perms ? perms[model] : undefined;
    return allowed ? allowed.includes(action) : false;
  }
}

TenantPermissionService

enum TenantRole { OWNER = 'OWNER', ADMIN = 'ADMIN', MEMBER = 'MEMBER' }

export class TenantPermissionService {
  // Define what each tenant role can do on each model/resource
  private static rolePermissions: Record<TenantRole, Record<string, string[]>> = {
    [TenantRole.OWNER]: {
      'Tenant':  ['create', 'read', 'update', 'delete'],
      'Project': ['create', 'read', 'update', 'delete'],
      'User':    ['invite', 'remove'], // e.g. can add/remove users
    },
    [TenantRole.ADMIN]: {
      'Tenant':  ['read', 'update'],
      'Project': ['create', 'read', 'update', 'delete'],
      'User':    ['invite'],
    },
    [TenantRole.MEMBER]: {
      'Tenant':  ['read'],
      'Project': ['read'],
      // no user management privileges
    },
  };

  constructor(private prisma: PrismaClient) {}

  hasPermission(tenant: { role: TenantRole }, action: string, model: string): boolean {
    const perms = TenantPermissionService.rolePermissions[tenant.role];
    const allowed = perms ? perms[model] : undefined;
    return allowed ? allowed.includes(action) : false;
  }
}

With these classes in place, whenever you handle a request that needs authorization, you use PermissionService. You pass in the requester’s ID (user or tenant), the action they want, and the resource. The service logs and enforces the rules defined above.

Conclusion

By organizing our RBAC system into a central PermissionService and specialized sub-services with static role definitions, we gain several benefits:

  • Maintainability: All permission rules live in one place (or a few well-defined places). Adding a new role or changing what it can do doesn’t require hunting through every endpoint.

  • Security: Every check goes through the same logic, reducing the chance of missing an authorization check. We avoid “security through obscurity” and make it easy to audit who can do what.

  • Scalability: As the app grows (more models, actions, tenants), the modular pattern scales naturally. Need a new tenant-level role? Update TenantPermissionService. Need a new model? Adjust the relevant maps. The core PermissionService stays stable.

  • Testability: Each service can be unit-tested in isolation. You can write tests asserting that Admin has permission to create User but Member does not, without touching actual endpoints or DB.

This modular RBAC pattern, backed by Prisma for data access, gives you a clear, robust foundation for access control in multi-tenant Node.js applications. It might add a bit of upfront design work, but the payoff in clarity and safety is well worth it.

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox

© 2026 Kuray Karaaslan. All rights reserved.