Implementing Role-Based Access Control in a Multi-Tenant Cross-Border Platform
When Taocarts grew from a single-operator reverse-Taobao tool into a platform where teams run purchasing, warehousing, and customer service under one roof, the first thing that broke wasn't performance — it was trust. One account had one login, and everyone shared it. A warehouse clerk could issue refunds. A customer-service rep could edit supplier prices. It worked when the "team" was one person; it fell apart at three.
Here is how we built Role-Based Access Control (RBAC) into a multi-tenant architecture without rewriting the whole app.
The core problem: one tenant, many jobs, one login
A tenant in our model is a reseller business. Inside it, there are distinct jobs: purchasing needs to place 1688 orders, warehousing needs to receive and ship, CS needs to reply and issue partial refunds, finance needs to see money but not touch products. The naive shared-login setup made every action attributable to nobody, which is a compliance nightmare for cross-border trade where order真实性 and fund flow are audited.
We had to solve three things at once:
- A user belongs to a tenant, and tenant data must never leak across boundaries.
- Within a tenant, a user gets only the permissions their job needs.
- Permission checks must be enforced server-side, not just hidden in the UI.
Step 1: Tenant isolation first, roles second
Before roles, we enforced tenant scoping on every query. Our Laravel backend uses a global scope that injects where tenant_id = auth.tenant_id on every model. This means even a buggy controller can't return another tenant's rows. RBAC rides on top of this: roles constrain what within the tenant, tenancy constrains whose.
Step 2: Roles as capability sets, not job titles
We resisted naming roles after people ("the warehouse guy"). Instead we modeled capabilities: purchase.create, order.refund, inventory.write, finance.read. A role is just a named bundle of these. When the business hires a new CS rep, we assign the cs role; if they later also help with light inventory, we add inventory.read without inventing a new title.
This kept the permission matrix small and reviewable. We currently run under a dozen capabilities, which cover everything from coupon management to overseas-warehouse outbound.
Step 3: Server-side enforcement with a Policy layer
Hiding a button in React is not security. Every mutating endpoint goes through a Laravel Policy that checks the capability against the authenticated user's role. A CS role hitting POST /refund returns 403 at the API, not just a grayed-out button. We also log every privileged action with who / tenant / action / object_id, so a finance audit can reconstruct any decision.
Step 4: Least privilege by default
New users get a minimal role with read-only access to their own lane. Broader capabilities are granted explicitly and reviewed quarterly. The temptation is to grant everything "to move fast"; we learned the hard way that a clerk who can refund is a refund incident waiting to happen.
What changed
After rolling this out, our customers stopped sharing passwords and started assigning roles. Support tickets about "someone changed my price" disappeared because no one outside purchasing could. More importantly, when a tenant's finance team asks "who approved this payout," we answer in seconds instead of never.
If you're building multi-tenant SaaS for real businesses, treat RBAC as a day-one concern, not a cleanup task. The cost of retrofitting access control onto a shared-login app is far higher than designing it in while the surface area is still small.
Tags: webdev, programming, security, automation