Role & Permission Architecture
One admin panel, many teams, none of whom should see the same things — with permissions as configuration rather than code.
- Period
- 2025
- Role
- Backend Engineer — designed and implemented the permission model
- Node.js
- Express.js
- MySQL
Overview
A database-driven role and permission system for the internal admin panel of a multi-tenant mobility platform. A super admin defines named roles, selects which modules each role can reach and which actions it can take within them, and attaches a role to a manager account at creation. Adding a role is a configuration change, not a deployment.
The Problem
An admin panel starts as a tool for one team and quietly becomes a tool for several. Operations needs to look at rides and export them. Finance needs transaction views. Support needs to act on individual records. Each new team either gets the whole panel — including everything they have no business seeing — or someone writes a code change to carve out a subset for them.
Both outcomes are bad in the same way: access is decided by whoever is available to edit the code, and it is invisible afterwards. There is no single place to answer 'what can this person see?' — the answer is spread across conditionals.
Requirements
- Named roles created at runtime, not enumerated in code
- Per-module access, and per-action granularity within a module — viewing a table is a different privilege from editing a record
- Delegation: a super admin can create roles and assign them without engineering involvement
- Tenant isolation: white-labeled clients configure their own roles without visibility into each other
- Extensible — a new module should be grantable without touching the permission system
Architecture
Permissions are stored as data: a role maps to a set of modules, and each module carries the actions the role may perform there — view, edit, add. A manager account references a role, so a person's capabilities are the resolved product of that mapping rather than a property of the account itself. Changing what a team can do means changing rows, and it applies to everyone holding that role at once.
The super admin sits above the model rather than inside it: it holds every permission implicitly and is the only role that can create other roles. That asymmetry is deliberate — a permission system where any sufficiently privileged role can grant itself more is a permission system with one role.
Granting a new module is additive. The module declares the actions it supports, and it becomes available to assign; no existing role changes, and nothing in the permission layer is aware of what any particular module does.
Trade-offs
- Module-and-action granularity is coarser than per-endpoint or per-field permissions. It cannot express 'may edit this column but not that one' — accepted, because a model an operations lead can configure correctly is worth more than one that expresses every conceivable rule and gets misconfigured
- Modelling permissions against the panel's modules couples the permission model to the shape of the interface: reorganising the UI moves the permission surface with it
- Permissions as data means a wrong permission is a bad row rather than a bad line of code — no type checker or code review catches it, so the configuration UI has to make the wrong thing hard to express
- Resolving a role's permissions on every request adds a lookup on the hot path, mitigated by caching the resolved set rather than the rules
Challenges
The important discipline is that hiding a control is presentation, not enforcement. A role that cannot see an export button must also be refused at the endpoint behind it, because the button is a convenience and the endpoint is the boundary. Every permission expressed in the interface needs a corresponding server-side check, and keeping those two in step as modules are added is the ongoing cost of the design.
Granularity was the recurring argument. Every request for a finer-grained rule is individually reasonable and collectively produces a model nobody can hold in their head. Holding the line at module-and-action level meant saying no to specific requests on the grounds that the system as a whole stays configurable.
Lessons Learned
Access control is an organisational problem wearing a technical costume. The hard part is not checking a permission; it is producing a model that a non-engineer can configure correctly and that an auditor can read.
Permissions belong in data when the set of roles is open-ended, and in code when it is closed. Choosing configuration means accepting that correctness moves from the compiler to the configuration interface — which then has to be designed as carefully as the model itself.
The most valuable property turned out to be legibility: one place that answers what a given role can do. Scattered conditionals can enforce the same rules and still leave nobody able to state what they are.