DEV Community

Oladejo Babatunde
Oladejo Babatunde

Posted on

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. 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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

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' },
];
Enter fullscreen mode Exit fullscreen mode

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',
  },
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  },
};
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (2)

Collapse
 
circuit profile image
Rahul S

This is genuinely one of the better RBAC writeups — the createdBy===userId runtime SoD check and the typed denial reasons are the parts most people skip. The gap I'd flag is where two of your own decisions quietly collide: you moved permissions into a mutable DB with an admin UI (rightly — weekly compliance changes shouldn't need a deploy), and your audit log records every decision (actor, action, granted/denied). But it records the decision, not the policy that produced it. So when an investigator asks the question regulated audit actually asks — "was this approval legitimate under the rules in force on the day it happened?" — current state can't answer it, because the role→permission mapping has been edited through that admin UI several times since. You can prove the check passed; you can't prove what the check was.

The fix lives in the same "audit everything" instinct, just extended to the policy itself: make the permission definitions append-only / effective-dated (temporal rows), or snapshot the resolved permission set into each AuditEntry so the decision carries its own justification. Otherwise "who could do X on 12 March" — the entitlement-recertification question SOX-style reviews lean on — stays unanswerable even though every individual decision was logged perfectly. Smaller related one: the 60s cache TTL is the right call for normal churn, but expiresAt lapsing isn't a change event that fires invalidate(), so time-boxed and revoked-for-cause access can outlive its window by up to a minute — usually harmless, occasionally the exact minute that matters.

Collapse
 
johnfrandsen profile image
John Frandsen

Good point on authorization being where enterprise systems quietly fail — I'd argue banking is the sharpest example because there's a regulatory authorization layer that sits below your application RBAC.

In EU/UK Open Banking (PSD2), the bank's API gateway enforces consent scopes that your application-level RBAC can't override. When a third party connects to a bank account, the consent defines exactly what they can access:

  • AISP (Account Information Service Provider) scope = read-only access to balances and transactions
  • PISP (Payment Initiation Service Provider) scope = can initiate payments (but only with explicit per-transaction consent)
  • PIISP (Payment Instrument Issuer Service Provider) scope = can check funds availability (yes/no, not the balance)

This consent is time-scoped (default 90 days, though the FCA recently removed mandatory re-authentication), revocable by the account holder at any time, and enforced at the bank's API level — not in your application. Your app's RBAC layer operates on top of these regulatory scopes.

The practical lesson for banking RBAC: always model the regulatory consent as a constraint layer, not just another role. An admin in your application shouldn't be able to see transaction data that the underlying bank consent doesn't permit — and that consent can expire or be revoked independently of your session management.

(I maintain open-banking.io — noting the affiliation, no link.)