All work
Dukaan Dost · Engineering case study

Permissions

A hundred-plus guarded routes, multi-role aggregation and a two-tier cache — built rather than bought, so the access model stayed ours.

Role
Acting CTO — architecture, build
Timeline
2024
Stage
In production · every request, every route
Outcome
No third-party vendor in the access path
The permission matrix for one role, with Order Management expanded into its sub-modules
Module → SubModule → Action, with View, Create, Edit and Delete scoped to All or Own
The problem

Buying it was faster. That was the problem.

The ERP needed real role-based access across more than a hundred routes, with people holding several roles at once and inheriting the union of them. Off-the-shelf identity products do this well and would have shipped sooner.

The catch was where the model would live. Buy it, and our access model lives in someone else's schema — which means every future module has to be expressible in their vocabulary, every migration waits on their roadmap, and the cost of leaving grows with every route we add.

100+
routes needing enforcement on both sides
Multi-role
users inheriting the union of their roles
Hot path
a check on every single request
What shipped

Before, and after

01
Model
Module → SubModule → Action, ours
A vendor's schema, rented
02
Enforcement
API and UI read the same tree
Two definitions, drifting
03
Cost per check
In-memory Map, Redis behind it
A database round trip
04
Denied access
Audited, with the attempted route
A 403 and no record
05
Brute force
Rate limiting and dynamic IP blocking
Unbounded retries
The decisions

What got chosen, and what got refused

The direction I killed — buy the identity layer

It was the faster plan and on a normal week it would have been the right one. I killed it because the access model is not a feature, it is the shape every other feature has to fit into. Renting that shape means every module we had not designed yet would have to negotiate with a vendor's idea of a role.

Vendor evaluation comparing hosted identity options
KilledThe bought option — faster to ship, expensive to leave

Two-tier cache, because the check is on every request

An in-memory Map in front of Redis on a five-minute TTL. Permissions change rarely and are read constantly, which is the exact shape a cache is for. Without it, correct authorisation would have made every endpoint measurably slower, and someone would eventually have proposed skipping it on the fast routes.

One tree, both sides

A PermissionGuard component gates the UI against the same Module → SubModule → Action tree the API enforces. Two separate definitions would have drifted within a quarter, and the drift always resolves the same way — a button that exists for a user who is not allowed to press it.

PermissionGuard hiding controls the user cannot access
The UI never offers what the API will refuse
Where it got hard

The parts that fought back

Multi-role aggregation is not a union of booleans

Two roles granting overlapping subtrees needed a defined merge, and most-permissive-wins has sharp edges when one role exists specifically to restrict.

Cache invalidation, predictably

A five-minute TTL means a revoked permission can survive five minutes. Acceptable for additions, not for revocations — those needed an explicit bust rather than waiting for expiry.

What I learned
  1. 01

    Build the thing that defines the shape of everything else. Buy the things that fit into it.

  2. 02

    An authorisation check that is slow will eventually be skipped by someone under deadline. Make it free instead of making it a rule.

  3. 03

    If the UI and the API disagree about permissions, the UI is lying to the user. One source of truth or none.

More work

Still scrolling? Let's talk.

hiteshpal.8097@gmail.com