
Development · · 6 min read
How I Designed RBAC for 15+ User Roles
- Django
- Security
- Architecture
- React
Two roles is an if-statement. Fifteen roles is a system, and if you build it like an if-statement you end up with permission checks scattered across a hundred files. Here's how I designed access control for NovaRestro instead.
NovaRestro, the restaurant platform I wrote about in Building NovaRestro: From Idea to Restaurant Management Platform, supports more than 15 staff roles. Owners, managers, cashiers, waiters, captains, kitchen and bar staff, inventory keepers, and a few more. Each one needs to do its job and nothing else.
The mistake I almost made
The first instinct is to check roles directly:
if user.role in ["owner", "manager", "cashier"]:
allow_refund()This works on day one. Then a restaurant asks for a "senior cashier" who can refund but a regular cashier can't. Now you search the codebase for every list that contains "cashier" and decide, one by one, whether the new role belongs there. Miss one and you have a security bug or a support ticket.
Role checks answer the wrong question. The code doesn't care who you are. It cares what you're allowed to do.
Permissions first, roles second
So the design has three layers:
- Permissions are small, specific actions defined in code:
orders.create,orders.void_item,billing.refund,menu.edit. - Roles are named bundles of permissions: "Waiter" has
orders.create,orders.add_item,tables.view. - Users are assigned one or more roles within a specific restaurant or branch.
Code only ever checks permissions. Roles exist for humans, to make assignment manageable.
# apps/accounts/permissions.py
class P:
ORDERS_CREATE = "orders.create"
ORDERS_ADD_ITEM = "orders.add_item"
ORDERS_VOID_ITEM = "orders.void_item"
ORDERS_VOID_SENT_ITEM = "orders.void_sent_item"
BILLING_TAKE_PAYMENT = "billing.take_payment"
BILLING_REFUND = "billing.refund"
BILLING_DISCOUNT = "billing.discount"
MENU_EDIT = "menu.edit"
INVENTORY_ADJUST = "inventory.adjust"
REPORTS_VIEW = "reports.view"
STAFF_MANAGE = "staff.manage"Permission strings are constants, not free text typed into views. A typo becomes an import error instead of a check that silently always fails.
Defining roles as data
Default roles live in a single file, which doubles as documentation. When a product owner asks "what can a captain do?", I can point at one place.
DEFAULT_ROLES = {
"waiter": {P.ORDERS_CREATE, P.ORDERS_ADD_ITEM, P.ORDERS_VOID_ITEM},
"captain": {P.ORDERS_CREATE, P.ORDERS_ADD_ITEM, P.ORDERS_VOID_ITEM, P.ORDERS_VOID_SENT_ITEM},
"cashier": {P.BILLING_TAKE_PAYMENT, P.BILLING_DISCOUNT},
"manager": {P.ORDERS_VOID_SENT_ITEM, P.BILLING_REFUND, P.MENU_EDIT, P.REPORTS_VIEW, P.STAFF_MANAGE},
# ...more roles
}These seed the database for each new restaurant. After that, an owner can adjust a role's permissions from the admin screen, or create a custom role, without a deploy. The "senior cashier" request becomes a two-minute settings change.
Notice the split between orders.void_item and orders.void_sent_item. Removing an item before it reaches the kitchen is harmless. Voiding one the kitchen has already cooked costs money and is a classic place for misuse. Those deserve different permissions. A lot of good RBAC design is noticing where one action is really two.
Scoping: permissions apply somewhere
A manager at Branch A should not be able to refund a bill at Branch B. So role assignments carry a scope:
class Membership(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
restaurant = models.ForeignKey(Restaurant, on_delete=models.CASCADE)
branch = models.ForeignKey(Branch, null=True, blank=True, on_delete=models.CASCADE) # null = all branches
roles = models.ManyToManyField(Role)Every permission check takes a target, and the check resolves the user's permissions for that target's restaurant and branch. There is no "global manager" short of the platform's own superusers.
One function for every check
All checks go through one function. Views, services, admin actions and background jobs all call the same thing.
def has_perm(user, perm: str, *, branch: Branch) -> bool:
perms = cached_permissions(user.id, branch.id) # resolved once per request
return perm in perms
def ensure_can(user, perm: str, *, branch: Branch) -> None:
if not has_perm(user, perm, branch=branch):
raise PermissionDenied(perm)In Django REST Framework, a small permission class wraps it:
class Requires(BasePermission):
def __init__(self, perm):
self.perm = perm
def has_object_permission(self, request, view, obj):
return has_perm(request.user, self.perm, branch=obj.branch)And the important part: the business logic in services.py calls ensure_can too. Even if someone adds a new endpoint and forgets the permission class, voiding a sent item still goes through void_item(), and void_item() still checks. Defense lives next to the rule it protects.
The frontend only hides, the backend decides
The React app needs to know permissions so it can hide buttons a user can't use. A waiter shouldn't see a refund button. So the API returns the current user's resolved permissions for the active branch, and a hook reads them:
export function useCan(perm: Permission) {
const { data } = useCurrentMembership();
return data?.permissions.includes(perm) ?? false;
}
function BillActions({ bill }: { bill: Bill }) {
const canRefund = useCan("billing.refund");
return canRefund ? <RefundButton billId={bill.id} /> : null;
}This is purely a UX layer. Hiding a button is not security. Anyone with dev tools can send the request anyway, which is exactly why every check also happens on the server.
If a permission is only enforced in the UI, it isn't enforced. The frontend decides what to show; the backend decides what happens.
Overrides without chaos
Real restaurants have exceptions. Say a manager steps out and wants a trusted waiter to approve one discount. Instead of handing over the manager's login (which happens more than anyone admits), the approach I prefer is a manager PIN override: the waiter's screen asks for a PIN from someone who has the permission, and the action is recorded under both people.
That's the general pattern for exceptions: grant one action, temporarily, with a record, rather than permanently widening a role.
Audit everything sensitive
Every sensitive action writes an audit entry: who, what, when, which branch, and who approved it if it was an override. Voids, refunds, discounts, price changes and role changes are all logged. An owner checking why yesterday's cash didn't match should be able to find the answer in a list, not by asking around the staff room.
Testing the matrix
With this many roles, I don't trust my memory. Tests are table-driven: for each default role and each sensitive service, assert whether the call succeeds or raises PermissionDenied.
- One test per role per sensitive action, generated from a matrix.
- A test that fails if a new permission constant isn't assigned to any role.
- A test that branch scoping blocks cross-branch access.
When someone changes a default role, the failing tests show exactly what that change grants or removes.
What I'd tell you
Check permissions, never roles. Treat roles as editable bundles for humans. Scope every assignment. Put one check function at the center and call it from your business logic, not just your views. Use the frontend for visibility only. And log every action that touches money. It sounds like a lot of structure for "who can click what", but with 15+ roles it's the structure that keeps the product sane. If you want the folder layout this sits in, see How I Structure React + Django Projects.
