Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

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:

  1. Permissions are small, specific actions defined in code: orders.create, orders.void_item, billing.refund, menu.edit.
  2. Roles are named bundles of permissions: "Waiter" has orders.create, orders.add_item, tables.view.
  3. 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.