Short answer
Most Django permissions-and-roles implementations fail because they model org charts, not authorization decisions.
Django provides strong primitives (User, Group, Permission), but it does not design your policy model for you.
If roles become the only axis, you get role explosion, patchwork exceptions, and cross-tenant data leaks.
What usually goes wrong
Typical symptoms after 6-12 months:
- every new feature introduces a new role instead of a capability,
- checks happen in views but not in queryset/service layers,
- endpoints return records users should not see,
owner-or-adminlogic is duplicated across many files,- nobody can answer "who can do what" from a single source.
This is an architecture problem, not a Django syntax problem.
1) Anti-pattern: role as a fixed permission bundle
When roles mirror your org structure (sales_manager_eu, night_support_junior), role count grows faster than product complexity.
How role explosion happens
- new exception -> new role,
- temporary access -> permanent role mutation,
- no capability decomposition (
invoice.read,invoice.approve,refund.execute).
After a year, nobody knows which role is canonical.
Better pattern
Define capability matrix first, then map roles to capabilities.
Roles should be an aggregation layer, not the core semantic model of authorization.
Trade-off: more upfront modeling, dramatically lower long-term change and audit cost.
2) Anti-pattern: endpoint-only authorization
A has_perm check in a view is insufficient if your queryset still overexposes data.
In production, authorization often fails on a perfectly legal GET, not on POST.
Where policy must be enforced
- entry layer (
permission_classes) decides if action is allowed, - data layer (queryset/repository/service) decides which records are visible,
- domain layer decides if mutation is valid in context.
# services/orders.py
from django.db.models import QuerySet
def visible_orders_for(user) -> QuerySet:
qs = Order.objects.all()
if user.is_superuser:
return qs
if user.has_perm("orders.view_all_orders"):
return qs.filter(company_id=user.company_id)
return qs.filter(owner_id=user.id)If policy does not constrain data scope, UI and route checks only hide the issue.
3) Anti-pattern: owner checks as full object-level model
obj.owner_id == request.user.id is a useful baseline, but it breaks once you add delegation, team workflows, proxies, and compliance requirements.
When owner-based access is enough
- small product,
- few resource types,
- no delegated responsibility,
- limited compliance pressure.
When to adopt object-level permissions
- access depends on user-resource relationship (reviewer, approver, delegate),
- permissions are contextual or time-bound,
- auditability of granted access is mandatory.
At this stage, django-guardian or a custom policy backend can help, but only with one central decision model.
4) Anti-pattern: no central policy map and no tests
The most expensive failure mode is scattered authorization logic without regression tests.
Small teams can survive this briefly; scaled teams cannot.
Minimal authorization test set
- Capability matrix tests: role -> allowed operations.
- Data-scope tests: user sees only allowed rows.
- Object-rule tests:
has_object_permissionfor critical actions. - Deny-by-default tests: no explicit allow means deny.
Without these tests, refactors frequently reopen old vulnerabilities.
Choosing authorization architecture in Django
Option A: Django Groups plus model permissions
Works well when:
- role set is stable,
- most decisions are global,
- object-level exceptions are rare.
Option B: DB-backed RBAC plus policy service
Works well when:
- capabilities change frequently,
- multiple bounded contexts share auth logic,
- central auditability is required.
Option C: Hybrid RBAC plus ABAC
Works well when decisions depend on context (tenant, region, ownership, status, feature flag).
RBAC sets baseline rights; ABAC refines them per request context.
Trade-off: highest flexibility, highest testing and observability cost.
60-day remediation plan
- Define capability matrix for top 3-5 resources.
- Collapse temporary roles into reusable capabilities.
- Move access scoping into queryset/service layer.
- Add deny-by-default and data-scope tests.
- Extract a dedicated policy module as single decision point.
- Log authorization decisions for critical actions.
- Formalize role-change governance and audit process.
- Add object-level permissions for high-risk exceptions.
Final verdict
Django is rarely the bottleneck.
The bottleneck is treating roles as a shortcut for all authorization decisions.
Reliable authorization starts with decision modeling and data scope control, then uses Groups, custom backends, or RBAC/ABAC hybrid where appropriate.
