Dev.to Security 🔐 Cybersecurity 👁 1 📖 7 min read

Building Role-Based Access Control for Enterprise Systems: Lessons from Banking Software

How I designed an authorization system that balances security, flexibility, and developer experience — without over-engineering it. The Problem No One Talks About Authentication gets all the blog posts. OA

How I designed an authorization system that balances security, flexibility, and developer experience — without over-engineering it.

The Problem No One Talks About

Authentication gets all the blog posts. OAuth flows, JWT debates, session vs. token — the internet is full of opinions. But authorization — deciding what an authenticated user can actually do — is where enterprise systems quietly fail.

I've built RBAC systems for multiple enterprise platforms, including internal tools for a regulated banking environment. The challenge isn't implementing roles. It's designing a system that:

  • Scales with organizational complexity (departments, hierarchies, cross-functional access)
  • Remains auditable (who approved what, when, and why)
  • Doesn't become a bottleneck for feature delivery
  • Is simple enough that non-engineers can understand the permission model

Here's what I've learned.

Why Simple RBAC Breaks in Enterprise

The textbook RBAC model — User → Role → Permissions — works great for SaaS products with 3 roles (admin, member, viewer). It falls apart in enterprise because:

1. People wear multiple hats. A facilities manager might also be a budget approver. A compliance officer might need read access across all departments but write access to none.

2. Context matters. The same "manager" role means different things in different departments. An engineering manager shouldn't approve procurement requests, even though they share a title with a procurement manager.

3. Temporary access is real. An auditor needs full read access for two weeks. An intern needs restricted access that expires. A contractor needs access to exactly one module.

4. Regulatory requirements demand granularity. In banking, you can't just have "admin." You need separation of duties — the person who initiates a transaction cannot be the person who approves it.

The Model I Use

After iterating across multiple projects, I've settled on a layered model:

User
  └── has many → UserRoles (with scope + expiry)
        └── belongs to → Role
              └── has many → RolePermissions
                    └── belongs to → Permission (resource + action + scope)

The key additions beyond basic RBAC:

Scoped Roles

A role assignment isn't global — it's scoped to a context:

interface UserRole {
  userId: string;
  roleId: string;
  scope: {
    type: 'global' | 'department' | 'project' | 'resource';
    scopeId?: string; // e.g., department ID
  };
  expiresAt?: Date;
  grantedBy: string;
  grantedAt: Date;
}

This means "Manager of Department X" is a different assignment than "Manager of Department Y" — same role, different scope. The user might have both, or just one.

Granular Permissions

Permissions aren't just "can access module." They're structured as resource + action + constraint:

interface Permission {
  resource: string;    // e.g., 'work_orders', 'budgets', 'reports'
  action: string;      // e.g., 'create', 'read', 'update', 'delete', 'approve'
  constraint?: string; // e.g., 'own_department_only', 'below_threshold'
}

A real permission set for a facilities supervisor might look like:

const facilitiesSupervisor: Permission[] = [
  { resource: 'work_orders', action: 'create' },
  { resource: 'work_orders', action: 'read', constraint: 'own_department' },
  { resource: 'work_orders', action: 'approve', constraint: 'below_50k' },
  { resource: 'budgets', action: 'read', constraint: 'own_department' },
  { resource: 'reports', action: 'read' },
  { resource: 'reports', action: 'export', constraint: 'own_department' },
];

Separation of Duties

For regulated environments, certain action combinations are explicitly forbidden:

const separationRules: SeparationRule[] = [
  {
    action1: { resource: 'purchase_orders', action: 'create' },
    action2: { resource: 'purchase_orders', action: 'approve' },
    reason: 'Initiator cannot be the approver (4-eyes principle)',
  },
  {
    action1: { resource: 'payments', action: 'initiate' },
    action2: { resource: 'payments', action: 'authorize' },
    reason: 'Payment initiation and authorization must be separate actors',
  },
];

These rules are checked at role assignment time (preventing conflicting roles) AND at action time (catching edge cases).

Implementation: Middleware + Policies

The enforcement layer has two levels:

Route-Level Middleware (Coarse Check)

Before any request reaches a controller, middleware verifies the user has at least one relevant permission for the resource:

// Middleware: checks "can this user interact with this resource at all?"
function requirePermission(resource: string, action: string) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const user = req.user;
    const hasPermission = await authService.checkPermission(user.id, {
      resource,
      action,
      scope: extractScope(req), // department from route params, etc.
    });

    if (!hasPermission) {
      auditService.logDenied(user.id, resource, action);
      return res.status(403).json({
        error: 'Insufficient permissions',
        required: `${resource}:${action}`,
      });
    }

    next();
  };
}

// Usage on routes
router.post('/work-orders', requirePermission('work_orders', 'create'), createWorkOrder);
router.patch('/work-orders/:id/approve', requirePermission('work_orders', 'approve'), approveWorkOrder);

Business Logic Level (Fine-Grained Check)

Inside the service layer, constraints are evaluated against the specific resource:

async approveWorkOrder(userId: string, workOrderId: string): Promise<WorkOrder> {
  const workOrder = await this.workOrderRepo.findById(workOrderId);
  const user = await this.userService.getWithRoles(userId);

  // Constraint: can only approve below threshold
  const constraint = await this.authService.getConstraint(userId, 'work_orders', 'approve');
  if (constraint === 'below_50k' && workOrder.estimatedCost >= 50000) {
    throw new ForbiddenException('Work order exceeds your approval threshold');
  }

  // Separation of duties: creator cannot approve
  if (workOrder.createdBy === userId) {
    throw new ForbiddenException('Cannot approve your own work order');
  }

  // Proceed with approval
  workOrder.status = 'approved';
  workOrder.approvedBy = userId;
  workOrder.approvedAt = new Date();

  await this.auditService.log({
    actor: userId,
    action: 'approve',
    resource: 'work_orders',
    resourceId: workOrderId,
    metadata: { estimatedCost: workOrder.estimatedCost },
  });

  return this.workOrderRepo.save(workOrder);
}

The Audit Trail: Non-Negotiable

In regulated environments, every permission check — whether granted or denied — must be traceable. My audit log captures:

interface AuditEntry {
  id: string;
  timestamp: Date;
  actor: string;           // who performed the action
  action: string;          // what they did
  resource: string;        // what resource type
  resourceId?: string;     // which specific resource
  outcome: 'granted' | 'denied';
  reason?: string;         // why denied (if applicable)
  metadata: Record<string, any>; // additional context
  ipAddress: string;
  sessionId: string;
}

This isn't just for compliance — it's invaluable for debugging. When someone reports "I can't access X," I can trace exactly which permission check failed and why.

Performance: Caching Without Compromising Security

Permission checks happen on every request. Without caching, you're hitting the database dozens of times per page load. But stale cache means users keep access after it's revoked — a security risk in banking.

My approach:

class PermissionCache {
  private readonly TTL = 60; // 60 seconds — short enough for security

  async getPermissions(userId: string): Promise<Permission[]> {
    const cached = await this.redis.get(`permissions:${userId}`);
    if (cached) return JSON.parse(cached);

    const permissions = await this.computePermissions(userId);
    await this.redis.set(`permissions:${userId}`, JSON.stringify(permissions), 'EX', this.TTL);
    return permissions;
  }

  // Immediate invalidation on role changes
  async invalidate(userId: string): Promise<void> {
    await this.redis.del(`permissions:${userId}`);
  }
}

Key decisions:

  • Short TTL (60s): Permissions refresh frequently, limiting the window of stale access
  • Immediate invalidation on changes: When a role is assigned or revoked, cache is cleared instantly
  • Compute once, check many: Permissions are flattened into a single array at cache time, so individual checks are O(1) lookups

What I Got Wrong (And Fixed)

Mistake 1: Hardcoding permissions in the codebase.

My first iteration had permissions defined in config files. This meant every permission change required a code deployment. In an enterprise environment where compliance teams need to adjust access weekly, this was unacceptable.

Fix: Store permissions in the database with an admin UI. The codebase only knows about permission shapes (resource + action), not specific role-permission mappings.

Mistake 2: No permission inheritance.

Initially, "Department Manager" didn't automatically include "Department Member" permissions. Every role had to explicitly list every permission, leading to massive duplication.

Fix: Role hierarchy with inheritance:

const roleHierarchy = {
  department_head: {
    inherits: ['department_manager'],
  },
  department_manager: {
    inherits: ['department_member'],
  },
  department_member: {
    inherits: [], // base level
  },
};

Mistake 3: Treating all denials the same.

A user denied because they lack the role is different from a user denied because of separation of duties. The UI needs to communicate this — one means "ask your admin for access," the other means "this action requires a different person."

Fix: Typed denial reasons that the frontend can act on:

type DenialReason =
  | { type: 'missing_role'; requiredRole: string }
  | { type: 'scope_mismatch'; userScope: string; requiredScope: string }
  | { type: 'separation_of_duties'; conflictingAction: string }
  | { type: 'expired_access'; expiredAt: Date }
  | { type: 'threshold_exceeded'; limit: number; actual: number };

Key Takeaways

  1. Start with the simplest model that works, then extend. Don't build role hierarchy, scoping, and constraints on day one unless you need them immediately. I add complexity when real requirements demand it.

  2. Audit everything. In enterprise, the audit trail isn't a nice-to-have — it's often a legal requirement. Build it from day one; retrofitting is painful.

  3. Separate policy from enforcement. The rules (who can do what) should be configurable without code changes. The enforcement (how checks happen) lives in code.

  4. Cache aggressively, invalidate immediately. Permission checks are hot paths. But stale permissions are security vulnerabilities. Short TTL + instant invalidation on changes is the balance.

  5. Design for the "no" case. Most of your work will be denying access correctly and communicating why. Good error messages save hours of support tickets.

The Bigger Picture

RBAC isn't a solved problem — it's a spectrum. SaaS apps need simple role checks. Enterprise systems need contextual, auditable, hierarchical authorization. Financial systems need all of that plus separation of duties and regulatory compliance.

The craft is in knowing where on that spectrum your system sits today, and building just enough flexibility to handle where it's going tomorrow.

Built with TypeScript, PostgreSQL, Redis, and Next.js.

Connect with me on LinkedIn or explore my work on GitHub.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.