DEV Community

Emmanuel Sunday
Emmanuel Sunday

Posted on

Taking Permissions a Step Further in Node.js (The Fall of Spaghetti Code)

So you scaffolded a blog post and handled permissions in a clean way…

Perhaps…

const isAllowedToUpdate = user.id === author.id || user.role;

const BlogPost = () => {
  return (
    <BlogPost>
      {isAllowedToUpdate && <EditBlogPost />}
    </BlogPost>
  );
};
Enter fullscreen mode Exit fullscreen mode

Shocker: That was a vulnerable piece of code right there.

It shows how fragile randomly handling permissions with if/else can be.

One little oversight and you're breaking a costly business logic.

So let's fix that.

Let's be more maintainable, reusable, and scalable.

That's the purpose of this article.

Let's get right in.


The Very Basics

I was recently the backend developer for a project that involved 4 roles:

  • Pharmacy
  • Customer
  • Consultant
  • Driver

For the sake of clarity, I'll reduce the resources involved to just 3:

  • Inventory
  • Medical records
  • Deliveries

So here's the basics of the relationship.

Customers can:

  • Read all inventories
  • Read only Medical records assigned to them
  • Create orders
  • Read only orders they create.

Pharmacies can:

  • Create inventories
  • Read inventories, but only ones they own
  • Update inventories, but only ones they own
  • Delete inventories, but only ones they own

Consultants can:

  • Create medical records
  • Read only medical records they own
  • Update only medical records they own
  • Delete only medical records they own

Drivers can:

  • Read deliveries assigned to them

You may also notice something in addition to all this: consultants have no business with inventory, pharmacies have no business with deliveries, and drivers have no business with medical records — and so on.


The Fast Way

In an Express project, you could quickly put something like this together:

if (user.role === "customer") {
  // ...
}

if (user.role === "consultant" && medicalRecord.consultantId === user.id) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

And even better, with middleware.

and have something as clean as this:

authorize(["consultant", "pharmacy"]);
Enter fullscreen mode Exit fullscreen mode

This restricts a particular route from being accessed by any role other than those passed in.

This solves a lot of problems — it protects routes from being accessed by other roles, and quickly narrows down the scope of concern.


The Rise of Spaghetti Code

Think about a situation where the resource in question is an "order" which a customer can only create (not update or delete), a pharmacy can only read if assigned to them, a driver can only read if assigned to them, and a consultant can only read if assigned to them.

My dear brothers and sisters…

You're ending up with:

authorize(["customer", "consultant", "driver", "pharmacy"]);
Enter fullscreen mode Exit fullscreen mode

Whereas you only needed each of these roles to just read "orders" assigned to them.

In a NestJS project, at this point, there's no point calling a roles guard (the Express equivalent).

Just protect the endpoint (controller) generally and move the role logic to the service.

And my dear brothers and sisters, this is how you end up with this...

if (user.role === "consultant") {
  if (record.consultantId === user.id) {
    // allow

  }
} else if (user.role === "pharmacy") {
  // ...
} else if (user.role === "driver"
Enter fullscreen mode Exit fullscreen mode

Repeated 20 different places.

This is how you have permission logic bleeding directly into the route handlers.

A spaghetti code.


The Fall of Spaghetti Code

Spaghetti code is basically duplicated business logic that eventually becomes inconsistent business logic.

It's complex code with a touch of inconsistencies, tight coupling, and a lack of modularity.

If we had to implement our scenario with the previous approach, we'd have very complicated, lengthy conditions multiplied across all the necessary routes.

What's the fix?


RBAC vs ABAC

RBAC and ABAC are the primary access control models common in business apps.

RBAC (Role-Based Access Control) gives access to resources based on roles. ABAC (Attribute-Based Access Control) uses attributes to grant access to resources.

RBAC is what we're familiar with. It's what we used earlier.

Both have their edge cases, their good and bad, their upsides, and their downsides.

You get the point.

RBAC is much simpler but struggles with multiple roles and nuanced multi-tenancy applications like the one illustrated above.

ABAC is much more discreet and rigid, but can be overkill at times.

So what do we do?

We go with ABAC. It's the best for our case scenario.


Implementing ABAC

With ABAC, we move away from "Who are you?" (role) to "Are you allowed to perform this specific action on this specific object?"

There are many ways to approach ABAC, but for simplicity and the context of our scenario, I'll lay the foundation and outline the most common approach in Node.js.

The Strategy: Decouple Logic from Controllers

The core idea: instead of checking user.role everywhere, you define policies per resource that evaluate attributes — who the user is, what the resource is, and what action is being taken.

A policy can look like this:

customer: ["create:orders", "view:ownOrders"],
pharmacy: ["view:ordersAssigned"]
Enter fullscreen mode Exit fullscreen mode

Roles, but with a set of defined actions they can perform, all in an array of strings.

This (string-based ABAC) works, but could even be better with Logic Map ABAC:

const policies = {
  customer: [
    { action: 'create', subject: 'Order' },
    { 
      action: 'read', 
      subject: 'Order', 
      condition: (user, order) => order.customerId === user.id 
    }
  ],
  pharmacy: [
    { 
      action: 'read', 
      subject: 'Order', 
      condition: (user, order) => order.pharmacyId === user.id 
    },
    { 
      action: 'update', 
      subject: 'Inventory', 
      condition: (user, inv) => inv.ownerId === user.id 
    }
  ]
};
Enter fullscreen mode Exit fullscreen mode

The point is, there are myriad ways you can achieve this.

Just pay attention to the goal

Instead of checking user.role everywhere, you define policies per resource that evaluate attributes — who the user is, what the resource is, and what action is being taken.

For a long time, the industry standard has been CASL.

So let's talk about it.

CASL

CASL (pronounced "castle") is an isomorphic authorization JavaScript library that manages user permissions by defining what actions (read, update, delete) a user can perform on specific subjects (e.g., Article, User).

It's based on ABAC.

A CASL inventory policy setup could look like this:

export const InventoryPolicy = (user, { can }) => {
  if (user.role === 'pharmacy') {
    can('manage', 'Inventory', { pharmacyId: user.id });
  }
  if (user.role === 'customer') {
    can('read', 'Inventory');
  }
};
Enter fullscreen mode Exit fullscreen mode

Based on this policy, pharmacies can only manage inventory they own, while customers can only read.

Now we need an ability.factory file.

Stay with me.


import { AbilityBuilder, Ability } from '@casl/ability';
import { InventoryPolicy } from './policies/inventory.policy';
import { MedicalPolicy } from './policies/medical.policy';
// ... import other policies

export function createAbilitiesForUser(user) {
  const builder = new AbilityBuilder(Ability);

  // We pass the user and the builder's methods to each policy
  InventoryPolicy(user, builder);
  MedicalPolicy(user, builder);
  // Add as many as you need...

  return builder.build();
}
Enter fullscreen mode Exit fullscreen mode

An ability factory is used in CASL to centralize and dynamically generate a user's permissions based on their identity or role.

It centralizes all authorization rules to live in one single file.

In our case, we had to put our policies inside.

And it eventually finds a way to build a central policy with which our app (across files) works with.

For your Express project, your middleware could then look like this:

const checkPermission = (action, subject) => {
  return (req, res, next) => {
    const ability = defineAbilitiesFor(req.user);

    if (ability.can(action, subject)) {
      return next();
    }

    return res.status(403).json({ message: "Forbidden" });
  };
};
Enter fullscreen mode Exit fullscreen mode

You see.

Apt.

And our route becomes as simple as:

router.post('/inventory', checkPermission('create', 'Inventory'), (req, res) => { ... });
Enter fullscreen mode Exit fullscreen mode

Beautiful.

Independent of whatever goes on with our policy, what we change, and what we do.

All it knows to check is "who has the ability" to create inventory based on the created policy.

This allows for flexibility and reusability.

For instance, if we introduce an admin role tomorrow, all we have to do is add it to our policy — and everything still works perfectly.

Now, route protection alone isn't enough.

Let's also reuse this in our service:

// In your Service
async function updateInventory(user, inventoryId, updateData) {
  const inventory = await db.inventory.find(inventoryId);
  const ability = defineAbilitiesFor(user);

  // ABAC logic happens here, away from the if/else mess
  if (ability.cannot('update', inventory)) {
    throw new ForbiddenException("You do not own this inventory record.");
  }

  return db.inventory.update(inventoryId, updateData);
}
Enter fullscreen mode Exit fullscreen mode

And voila.

This solves every edge case.

For instance, you may notice in our inventory policy, we had something like this...

if (user.role === 'pharmacy') {
    can('manage', 'Inventory', { pharmacyId: user.id });
  }
Enter fullscreen mode Exit fullscreen mode

This means pharmacies can manage inventory as long as they own it.

So when we do this...

router.post('/inventory', checkPermission('create', 'Inventory'), (req, res) => { ... });
Enter fullscreen mode Exit fullscreen mode

...we're filtering the forbidden users at the route level.

Immediately.

They never get to the service.


The Upsides

  • Policies live in one place. Add a role, update the policy file. Done.
  • Controllers stay dumb. They declare what permission is needed, not how to evaluate it.
  • Services stay clean. They check ability against the actual object, not the user's role string.
  • Business logic stays consistent. Because it only exists once.

This is also similarly beautiful for NestJS projects.

If there are requests, I could make a NestJS implementation for this.


I'm a solo developer who's currently a free agent — openly looking for software engineering roles. My portfolio is at www.me.soapnotes.doctor.

Thank you so much!

Top comments (0)