Designing Role-Based Access Control for a Multi-Brand B2B Trade Portal

2025-10-06

When you're building a platform that trade customers, internal staff, and managers all log into — each seeing a different slice of the same system — access control stops being a feature and becomes the architecture. Here's how I approached it for a multi-brand B2B portal serving a catalogue of over 780,000 parts.


The Requirement

The portal had to serve several audiences at once, all from the same data:

  • Trade customers, buying on account, who should see their own pricing, their own order history, and nothing else.
  • Internal sales staff, who needed to act on behalf of customers and see across accounts.
  • Managers, who needed reporting and oversight.
  • Admins, who configured the system itself.

On top of that, it was multi-brand — several storefronts sharing one backend and one 780,000-part catalogue, each presenting a tailored view. Every user, in effect, was looking at the same underlying data through a different lens. Get the lens wrong and you don't just show someone the wrong button — you potentially leak one customer's pricing or orders to another. In B2B, that's not a cosmetic bug, it's a breach of trust with an account that might be worth six figures a year.

Database-Driven RBAC

The instinct when you have four user types is to hard-code them: if (user.role === "admin") scattered through the codebase. I've maintained systems built that way, and they rot. Every new permission means a code change, a deploy, and a hunt for all the places the old check lived.

So roles and permissions were modelled in the database instead. A role is a row. A permission is a row. The link between them is data, not code. Adding a capability or spinning up a new role became a configuration change rather than a deployment, and — crucially — there was a single source of truth for "what can this person do" rather than the answer being smeared across dozens of conditionals.

The principle I held onto was that access control has to be enforced at the data and API layer, not the UI. Hiding a button is a usability nicety. It is not security. Anyone can open dev tools, read the network tab, and replay a request. So every protected page and every API endpoint checked permissions server-side against the same role model. The UI hiding the button and the endpoint rejecting the request were two expressions of one rule, not two separate implementations that could drift apart.

// The endpoint is the security boundary — not the hidden button.
const session = await auth();
if (!can(session.user, "orders:view", { accountId })) {
  return new Response("Forbidden", { status: 403 });
}

A "View-As-Role" QA Mode

The hardest thing about role-based UIs is that they hide their own bugs. If you're logged in as an admin, you see everything, so everything looks fine — and you never notice that a trade customer is quietly being shown a management report they should never have access to.

So I built a "view-as-role" mode for QA: testers could see exactly what a given role saw, without juggling a drawer full of test accounts. It made the invisible visible. Bugs that would otherwise only have shown up in a customer's browser — the worst possible place to find them — showed up in testing instead. Several genuine over-exposure issues got caught this way that a normal admin-eyed pass would have sailed straight past.

The Stack

  • Next.js 15 / React 19 for the application, with server components doing the permission checks close to the data.
  • NextAuth v5 for authentication and session handling.
  • Node APIs over MongoDB and PostgreSQL — the right store for the right shape of data, with the relational side carrying the structured account and catalogue data.
  • AWS S3 for asset storage.
  • Third-party integrations: Stripe for payments, DVLA for vehicle lookups, and TecDoc for the parts catalogue data.

Each integration was its own little trust boundary, which only reinforced the central discipline: never assume the caller is who the UI says they are.

Reflection

The thing I keep coming back to is the difference between least privilege from day one and retrofitting it later. It is genuinely hard to add real access control to a system that assumed everyone could see everything — you end up auditing every query, every endpoint, every join, trying to prove a negative. Designing for it from the start, with roles as data and the API as the boundary, meant the answer to "can this person do this?" lived in one place and could be reasoned about.

And the line I'd underline for anyone building this kind of platform: the button being hidden and the endpoint being protected are not the same thing. One is what the user sees. The other is what's actually true. Only one of them keeps your customers' data safe.