Horilla CRM stacks four permission layers on top of Django auth: model-level CRUD (including _own variants), field-level read/write/hide, row ownership via OWNER_FIELDS, and hierarchical roles.
This post is Part 4 of 28 in the Horilla CRM Technical Blog series.
What is the four-layer permission model?
The four-layer permission model enables you to:
- Model permissions — view_lead, view_own_lead, etc.
- Field permissions — FieldPermission per role or user
- Row permissions — OWNER_FIELDS on the model
- Role hierarchy — parent_role cascading access
Overview
flowchart TB
A[Request] --> B{Model permission?}
B -->|view_lead| C[Full access]
B -->|view_own_lead only| D{Row owner?}
D -->|lead_owner = user| E[Scoped access]
D -->|no| F[403]
C --> G{FieldPermission}
E --> G
G --> H[readonly / hidden / readwrite per field]
H --> I[Role hierarchy may expand perms]
| Layer | Mechanism | Example |
|---|---|---|
| 1. Model | Django Permission + _own variants | leads.view_lead, leads.view_own_lead |
| 2. Field | FieldPermission model | Hide annual_revenue from SDR role |
| 3. Row | OWNER_FIELDS on model | Only edit leads you own |
| 4. Role | Role with parent_role FK | Manager inherits rep permissions |
Feature registration (registration.py, Part 1) wires models into permission generation and admin UIs.
Layer 1: Model permissions
Horilla generates standard CRUD permissions per registered model, plus owner-scoped variants:
| Permission | Meaning |
|---|---|
| leads.view_lead | View any lead in the company |
| leads.view_own_lead | View leads where user is in OWNER_FIELDS |
| leads.change_lead | Edit any lead |
| leads.change_own_lead | Edit owned leads only |
| leads.add_lead / leads.delete_lead | Create/delete (with _own variants where applicable) |
Enforcing on views
@method_decorator(
permission_required_or_denied(["leads.view_lead", "leads.view_own_lead"]),
name="dispatch",
)
class LeadListView(LoginRequiredMixin, HorillaListView):
...
permission_required_or_denied grants access if the user has either full or own permission; row filtering happens at queryset level (Layer 3).
Menu items use the same pattern:
"perm": ["leads.add_lead"] # floating menu create button
Layer 2: Field permissions
# horilla/contrib/core/models/user.py
class FieldPermission(models.Model):
PERMISSION_CHOICES = [
("readonly", "Read Only"),
("readwrite", "Read and Write"),
("hidden", "Don't Show"),
]
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, ...)
role = models.ForeignKey(Role, null=True, blank=True, ...)
content_type = models.ForeignKey(HorillaContentType, on_delete=models.CASCADE)
field_name = models.CharField(max_length=255)
permission_type = models.CharField(max_length=20, choices=PERMISSION_CHOICES)
Field permissions attach to a user or a role for a specific model field.
Where it applies
- HorillaFormMixin — widgets rendered readonly or excluded on forms
- Detail templates — fields hidden via template tags
- Bulk update — restricted fields omitted
HorillaCoreModel.field_permissions_exclude lists audit/system fields that should not appear in the field-permission admin UI.
Admin configuration
Settings → Groups & Permissions → Field Permissions UI (horilla/contrib/core/views/groups_and_permissions/) bulk-saves FieldPermission rows per role.
Layer 3: Row-level ownership
On the model:
# horilla_crm/leads/models/base.py
class Lead(HorillaCoreModel):
lead_owner = models.ForeignKey(settings.AUTH_USER_MODEL, ...)
OWNER_FIELDS = ["lead_owner"]
OWNER_FIELDS is a list of FK field names that define ownership. Generic views check:
- Does user have change_lead? → allow
- Else does user have change_own_lead and lead.lead_owner == request.user? → allow
- Else → deny action / hide button
In list view actions and columns
lead_permission = {
"permission": "leads.change_lead",
"own_permission": "leads.change_own_lead",
"owner_field": "lead_owner",
}
horilla.contrib.generics uses owner_field to evaluate row-level access per object without custom view code.
Queryset filtering
OwnerFiltersetMixin on filtersets narrows list/kanban querysets when the user only has _own_ permissions — users never see rows they cannot access, even before row actions run.
Layer 4: Role hierarchy
# Role model (horilla.contrib.core)
class Role(HorillaCoreModel):
parent_role = models.ForeignKey("self", null=True, blank=True, ...)
Roles form a tree. Child roles can inherit permissions from parents (configured in the role admin). This maps to real org charts: SDR → Sales Manager → VP Sales.
Combine roles with:
- Django groups (optional bridge)
- Per-user overrides
- Field permissions at role level
How the four layers interact on one click
Scenario: SDR with view_own_lead, change_own_lead, role “SDR” where annual_revenue is hidden.
- Opens lead list → queryset filtered to lead_owner=request.user (row + model)
- Sees columns except hidden fields (field)
- Clicks Edit on owned lead → allowed (row + model)
- Edit form shows revenue as hidden (field)
- Tries another rep’s lead URL → 403 (row)
Permission checklist for new models
- Register model in registration.py (generates permissions)
- Set OWNER_FIELDS if records have an assignee FK
- Use permission_required_or_denied with full + _own perms on views
- Pass owner_field in list actions and col_attrs
- Use HorillaModelForm / HorillaMultiStepForm for field enforcement
- Assign default permissions to roles in admin after deploy
Security notes
- Company isolation (Part 2) is orthogonal — users only see rows for active_company and pass permission checks.
- Use all_objects only in trusted admin/report code.
- The field hidden is UI-level — API serializers should respect the same rules for DRF endpoints.
Benefits of Permissions in Horilla CRM
- Sales reps see only their records without custom view code
- Hide sensitive fields (e.g. revenue) per role
- Menu and HTMX actions respect the same rules
- Works alongside company-level tenant isolation
- Scales to complex org charts via role trees
The four-layer model lets Horilla CRM mirror real sales organizations. Register models, set OWNER_FIELDS, and use permission_required_or_denied with _own variants on every view.
Continue the series
Previous: Part 3 —How horilla.contrib.generics Powers CRM List, Kanban, and Detail UIs
Next: Part 5 — Why Horilla CRM Uses HTMX Instead of React for a Fast and Server-Rendered UI
More posts are at Horilla Blogs; share feedback on GitHub.