As business software expands beyond a few internal users, access control quickly becomes a critical security and compliance requirement.
A Junior Sales Associate should not have permission to view executive payroll data. A freelance contractor should only see tasks assigned directly to them. An external auditor needs read-only access without export capabilities.
Role-Based Access Control (RBAC) is the standard framework for managing these boundaries predictably.
It is also where custom business applications most often fail an audit. In our experience, permission bugs are rarely exotic exploits — they are a missing check on one export endpoint, a role that quietly accumulated permissions over two years, or a deactivated contractor whose credentials still work. This guide covers the schema, the code, and the operational practices that prevent those failures.
Core Elements of an RBAC Schema
Users ──(assigned to)──► Roles ──(composed of)──► Permissions
│ │ │
└─ John Doe └─ Financial Manager └─ invoices:create
└─ invoices:approve
└─ reports:export
In relational terms, that diagram is four tables: permissions, roles, role_permissions, and user_roles. Two design decisions determine whether the model survives contact with real customers:
1. Use resource:action codenames, not friendly labels. invoices:approve is unambiguous; "Can approve invoices" is not, and it cannot be matched reliably in code. Keep a separate display label for the admin UI.
2. Decide early whether roles are global or per-organisation. In multi-tenant software, a user may be an Admin in one client organisation and a Viewer in another. That means the assignment table needs a tenant or organisation column:
| Table | Columns |
|---|---|
permissions | id, codename (invoices:approve), description |
roles | id, organisation_id (nullable = system role), name, is_system |
role_permissions | role_id, permission_id |
user_roles | user_id, role_id, organisation_id, granted_by, granted_at |
That last table is worth treasuring. Recording who granted what, and when, turns "why does this person have access?" from an unfalsifiable argument into a query.
3. Never hardcode roles. if user.role == "manager" appears in every RBAC codebase that later becomes unmaintainable, because a customer will eventually want a "Regional Manager" that behaves like a manager but without approvals. Roles are data; permissions are the contract your code checks against.
Implementing RBAC in Django REST Framework
In Django, rather than hardcoding if user.is_staff: across your views, build declarative custom permission classes:
from rest_framework.permissions import BasePermission
class HasPermission(BasePermission):
def __init__(self, required_permission):
self.required_permission = required_permission
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
return request.user.role.permissions.filter(
codename=self.required_permission
).exists()
One important caveat, because this pattern is copied endlessly and it fails in practice: DRF instantiates every class in permission_classes with no arguments. So HasPermission above — whose __init__ requires a required_permission — will raise TypeError the moment you actually use it in a view. It looks right in an article and breaks in a codebase.
Two patterns that work:
Pattern A — a factory that returns a permission class (declare on the view):
class HasPermission(BasePermission):
required_permission = None
def has_permission(self, request, view):
if not request.user or not request.user.is_authenticated:
return False
return request.user.has_perm_codename(self.required_permission)
def require(permission):
return type("RequirePermission", (HasPermission,), {"required_permission": permission})
class InvoiceApproveView(APIView):
permission_classes = [IsAuthenticated, require("invoices:approve")]
Pattern B — a single class reading a required_permissions attribute from the view (declare in the view body):
class HasRequiredPermissions(BasePermission):
def has_permission(self, request, view):
required = getattr(view, "required_permissions", [])
if not required:
return True
return all(request.user.has_perm_codename(p) for p in required)
class InvoiceApproveView(APIView):
permission_classes = [IsAuthenticated, HasRequiredPermissions]
required_permissions = ["invoices:approve"]
Pattern B is usually the better default: the required permissions sit next to the view they protect, which makes review easier and makes missing checks obvious in a diff.
Two implementation details that eliminate entire classes of bug:
class User(AbstractUser):
def permission_codenames(self):
key = f"perms:{self.pk}"
cached = cache.get(key)
if cached is None:
cached = set(
Permission.objects.filter(
roles__user_roles__user=self
).values_list("codename", flat=True)
)
cache.set(key, cached, 300) # 5 minutes
return cached
def has_perm_codename(self, codename):
return codename in self.permission_codenames()
First, cache the permission set per request or for a few minutes rather than querying it on every check. A view with ten permission checks across a list of objects can otherwise produce dozens of queries. Second, invalidate that cache when roles change — one signal handler on user_roles saves you from the most confusing bug in RBAC, where a user keeps access after being demoted because the cache still holds their old permissions.
Also handle the superuser break-glass path deliberately. Django's is_superuser bypasses permission checks, which is fine for a platform engineer and dangerous if one support account has it. Log every superuser action, restrict the flag to a handful of accounts, and review the list quarterly.
Beyond Roles: Objects, Attributes and Relationships
Plain RBAC answers "can this role do this action?" It does not answer "on which records?" Four patterns cover the gap, and serious applications usually need at least two of them:
| Model | Question answered | Example | When to add it |
|---|---|---|---|
| RBAC | Can this role perform this action? | Manager can approve invoices | Always — the baseline |
| Object-level permissions | On which specific records? | Manager can approve invoices only for their department | When users see scoped data |
| ABAC (attributes) | Under which conditions? | Approve up to $10,000; above that require Finance | When money or risk thresholds exist |
| ReBAC (relationships) | Based on connection to the record? | Only the assigned caseworker, plus their supervisor | When access follows ownership graphs |
The attribute dimension is where approval workflows live: a single "Manager" role with a $10,000 limit and a $50,000 limit is really two roles in disguise, and modelling thresholds as attributes keeps the role list from exploding.
The role explosion problem is worth naming because it is the most common long-term RBAC failure. Start with four roles; after two years of customer-specific requests you have sixty, almost identical, and nobody can reason about them. Defences:
- Ship role templates (Admin, Manager, Contributor, Viewer, Auditor) that customers clone rather than edit
- Make custom roles a paid or admin-only feature so they are created deliberately
- Refuse to add permissions for one customer's edge case if a scoped object permission would solve it
- Audit the role list every quarter, merging anything with near-identical permission sets
Frontend Route & UI Guards in Vue 3
Never rely solely on frontend hiding for security. The backend must always enforce authorization.
However, good UX requires hiding UI buttons and navigation links that the user cannot access:
// Vue 3 Custom Directive: v-can
app.directive('can', (el, binding) => {
const userPermissions = useAuthStore().permissions;
if (!userPermissions.includes(binding.value)) {
el.style.display = 'none';
}
});
Two practical notes on this directive. Prefer el.remove() or a conditional render over display: none for anything sensitive — hidden elements remain in the DOM and are readable by anyone with developer tools. And check the same permission in the route guard, not only on buttons:
router.beforeEach((to) => {
const required = to.meta.permission;
if (required && !useAuthStore().permissions.includes(required)) {
return { name: 'forbidden' }; // not a redirect to login
}
});
Show a "you don't have access to this" page rather than bouncing users to login. A login redirect makes users think the session expired and trains them to re-authenticate, which is precisely the wrong habit.
The permissions in the frontend store should come from a single endpoint (/api/me/permissions/) that returns the resolved permission set. Do not have the frontend reconstruct permissions by inspecting role names — that duplicates business logic in two languages, and they will drift.
Testing Permissions Like You Mean It
Authorization bugs are silent. Nothing crashes when a Viewer exports payroll data; the request simply succeeds. That is why permissions need tests as deliberate as billing code:
- A permission matrix test. For each role, assert the expected allow/deny result against each sensitive endpoint. Written once as a table, run in CI on every pull request, this catches the overwhelming majority of regressions.
- Test the negative case explicitly. For every "staff can do X" test, add "contractor cannot do X". Untested denials are the ones that leak.
- Test cross-tenant access. Log in as Org A's admin and request Org B's invoice by ID. A 404 is correct here — 403 confirms the record exists, which is itself an information leak.
- Test after role change. Change a user's roles and confirm both the API and the UI reflect it within one cache lifetime.
- Test with deactivated accounts. A deactivated user's existing session must fail on the next request.
Key Takeaways
- Principle of Least Privilege: Users should only have the minimum permissions needed to perform their job.
- Never trust the client: Enforce permissions at the API endpoint and database layer.
- Decouple Roles from Permissions: Assign permissions to roles, and roles to users.
A fourth, learned the hard way: make permission changes auditable and reversible. Log every grant and revoke with the actor and reason, snapshot the role set before bulk changes, and provide an "effective permissions" view per user. When a client asks why someone could see a folder they should not have, "here is exactly when that permission was granted, by whom, and from which role" is the only answer that ends the conversation.
Related reading: Compliance for custom web applications is where access control gets audited; multi-tenant architecture covers tenant-scoped permissions; and the client portal guide shows these rules in a customer-facing product. Need an enterprise-grade web application with sophisticated security and compliance controls? Consult with KEHEM IT.
Permissions getting tangled? Describe the roles and we will tell you whether your model holds up to an audit.
Have a project in mind?
KEHEM designs and builds thoughtful websites, SaaS products, and business systems.