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 permissionsview_lead, view_own_lead, etc.
  • Field permissionsFieldPermission per role or user
  • Row permissionsOWNER_FIELDS on the model
  • Role hierarchyparent_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]
LayerMechanismExample
1. ModelDjango Permission + _own variantsleads.view_lead, leads.view_own_lead
2. FieldFieldPermission modelHide annual_revenue from SDR role
3. RowOWNER_FIELDS on modelOnly edit leads you own
4. RoleRole with parent_role FKManager 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:

PermissionMeaning
leads.view_leadView any lead in the company
leads.view_own_leadView leads where user is in OWNER_FIELDS
leads.change_leadEdit any lead
leads.change_own_leadEdit owned leads only
leads.add_lead / leads.delete_leadCreate/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:

  1. Does user have change_lead? → allow
  2. Else does user have change_own_lead and lead.lead_owner == request.user? → allow
  3. 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.

  1. Opens lead list → queryset filtered to lead_owner=request.user (row + model)
  2. Sees columns except hidden fields (field)
  3. Clicks Edit on owned lead → allowed (row + model)
  4. Edit form shows revenue as hidden (field)
  5. 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.

Share this article