Django’s built-in permission system is great at answering one important question:

“Can this user edit Opportunities?”

But real-world CRM applications usually need to ask a more specific question:

“Can this user edit this particular Opportunity?”

Those are two very different questions.

A salesperson might be allowed to edit their own Opportunities, while another salesperson may only be allowed to view an Opportunity. At the same time, that second salesperson might have been explicitly added to one particular Opportunity’s team and given permission to edit it.

This is where record-level access comes in.

Horilla CRM handles this by keeping Django’s normal permission system and record-level access rules as two separate layers. The result is a system where role permissions decide what a user is capable of doing, while ownership and team access decide which records they can actually do it to.

Let’s look at how this works and, more importantly, how you can use the same approach when adding record-level access to your own Horilla model.

Why Django permissions alone aren’t enough

Django’s standard permissions work at the model level.

For example, a user might have:

view_opportunity
change_opportunity
delete_opportunity

These permissions tell Django that the user is allowed to perform those operations on Opportunities.

But they don’t tell Django whether the user should be able to edit every Opportunity.

That’s why Horilla also has “own” permissions such as:

view_own_opportunity
change_own_opportunity
delete_own_opportunity

These permissions provide the first part of the access decision.

The next question is:

What exactly counts as “own”?

That’s where Horilla’s record-level scoping logic comes in.

Horilla uses two layers of access control

The easiest way to understand the architecture is to think of it as two separate checks.

Layer 1: Role-level permissions

The first layer comes from Django’s permission system.

Horilla automatically creates both the normal and “own” versions of the standard model permissions through a signal in:

horilla/contrib/core/signals.py

For an Opportunity, that means permissions such as:

view_opportunity
view_own_opportunity

change_opportunity
change_own_opportunity

delete_opportunity
delete_own_opportunity

These are still normal Django permissions and can be checked with:

user.has_perm(…)

This layer answers:

“Is this user allowed to perform this type of operation?”

Layer 2: Record-level access

Once the user has the required permission, Horilla needs to determine which records that permission applies to.

This is handled by the generic queryset and object-access logic.

Two pieces are particularly important:

OWNER_FIELDS

and:

granted_access_filter
has_granted_access

OWNER_FIELDS identifies the field that determines ownership.

For example:

OWNER_FIELDS = [“owner”]

means the owner field determines whether a record belongs to the current user.

The granted-access hooks provide another way for a record to become accessible. Instead of being the actual owner, a user can be explicitly granted access to a particular record.

This separation is important. 

A team member being granted edit access to an Opportunity doesn’t automatically give them permission to edit Opportunities. They still need the appropriate Django permission, such as change_own_opportunity.

The grant determines which records they can use that permission on.

The small hooks that make the system work

Horilla keeps the granted-access mechanism intentionally lightweight.

The generic helpers are located in:

horilla/contrib/generics/views/helpers/queryset_utils.py

The model can expose a filter for queryset-level access:

def get_granted_access_filter(model, user, action):
    getter = getattr(model, "granted_access_filter", None)
    if getter is None:
        return None
    return getter(user, action)

And it can expose an object-level check:

def user_has_granted_access(obj, user, action):
    checker = getattr(obj, "has_granted_access", None)
    if checker is None:
        return False
    return bool(checker(user, action))

The interesting part here is that there is no central registration system.

The generic layer doesn’t need to know that Opportunity has a team model or how that team model works.

It simply asks:

“Does this model provide a granted_access_filter?“

If the answer is yes, the generic layer uses it.

For an individual object, it asks:

“Does this object provide has_granted_access?“

Again, if the method exists, it can use it.

This is essentially duck typing.

The model opts into the feature simply by implementing the expected methods.

The action is passed into the access check

The hooks aren’t just checking whether a user has access in general.

They receive an action:

view
change
delete

That distinction is important because different access levels can provide different capabilities.

For example, a user might be allowed to view a record but not modify it.

That is exactly how Opportunity team access works.

Opportunity team access

The Opportunity model defines three team access levels:

ACCESS_LEVEL_CHOICES = [
(“read”, (“Read Only”)), (“edit”, (“Read/Write”)),
(“owner”, _(“Owner”)),
]

These levels are stored on the Opportunity team member record.

The same team membership can therefore mean different things depending on the access level assigned to the user.

A read-only member can see the Opportunity.

An edit-level member can see and modify it.

An owner-level member has the highest level of access.

The Opportunity model then maps these levels to actions:

class Opportunity(HorillaCoreModel):
    OWNER_FIELDS = ["owner"]


    TEAM_ACCESS_LEVELS_FOR_ACTION = {
        "view": ["read", "edit", "owner"],
        "change": ["edit", "owner"],
        "delete": ["edit", "owner"],
    }

This mapping makes the access rules very easy to understand.

For viewing:

read
edit
owner

are all accepted.

For changing:

edit
owner

are accepted.

And for deleting:

edit
owner

are accepted.

So a read-only team member can see the Opportunity but cannot edit or delete it.

Filtering the queryset

The class method granted_access_filter() handles the queryset side of the feature.

A simplified version looks like this:

@classmethod
def granted_access_filter(cls, user, action):
    levels = cls.TEAM_ACCESS_LEVELS_FOR_ACTION.get(action)


    if not levels:
        return models.Q(pk__in=[])


    return models.Q(
        opportunity_team_members__user=user,
        opportunity_team_members__opportunity_access__in=levels,
    )

The method receives the current user and the action being performed.

It first finds which access levels are valid for that action.

Then it builds a Django Q object that matches Opportunities where:

  • the current user is a team member, and
  • their team access level is sufficient for the requested action.

The important thing is that this returns a Q object rather than directly querying the database.

That allows the generic views to combine the granted records with the normal ownership-based queryset.

Conceptually, the final queryset becomes something like:

records I own

OR

records explicitly granted to me

This is what allows team members to see records that they don’t technically own.

Checking access to a single record

Querysets aren’t enough.

Horilla also needs to answer the same question when it already has one specific Opportunity object.

That’s what has_granted_access() handles:

def has_granted_access(self, user, action):
    levels = self.TEAM_ACCESS_LEVELS_FOR_ACTION.get(action)

    return bool(levels) and self.opportunity_team_members.filter(
        user=user,
        opportunity_access__in=levels,
    ).exists()

Instead of returning a queryset filter, this method returns a simple boolean.

If the user has a team membership with a suitable access level, it returns True.

Otherwise, it returns False.

This distinction between queryset-level and object-level checking is important because Horilla has many places where it needs to determine access.

Making the access rules easier to use

Opportunity also provides small wrapper methods:

def is_view_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “view”)

def is_change_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “change”)

def is_delete_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “delete”)

These methods combine two possible sources of access:

  1. The user is the actual owner.
  2. The user has been granted access through the team.

That makes them particularly useful when configuring row actions.

Instead of asking only:

Is this user the owner?

the UI can ask:

Does this user have change access to this record?

That’s a much better abstraction.

How this affects Edit and Delete buttons

This becomes visible in the Opportunity list and kanban views.

Previously, an action could be configured using an owner field:

{
"action": "Edit",
"owner_field": "owner",
}

That works when only the actual owner should be allowed to perform the action.

But it doesn’t work for team members.

A team member might not be the value stored in owner, yet they may still have permission to edit the record.

So the configuration can instead use:

{
    "action": "Edit",
    "owner_method": "is_change_granted",
}

Now the action checks the model method rather than simply comparing the owner field.

The same idea can be used for delete actions:

{
    "action": "Delete",
    "owner_method": "is_delete_granted",
}

This makes the UI reflect the actual access rules instead of assuming that ownership is the only source of access.

One supporting change in action permissions

There is an important detail behind this.

The action permission logic in:

horilla/contrib/generics/templatetags/horilla_tags/action_tags.py

also needs to understand owner_method.

When an action uses an owner_method, the method itself already knows how to determine access.

For example:

is_change_granted(user)

already checks ownership and team access.

So the action shouldn’t additionally require a separate own_permission check at that point.

Otherwise, the system could end up checking the same concept twice and incorrectly hiding an action that the model has already determined is allowed.

Adding record-level access to your own model

Once you understand the Opportunity implementation, adding the same pattern to another model becomes fairly straightforward.

Imagine you have a Project model and want users to collaborate on individual projects.

You could introduce a model such as:

class ProjectCollaborator(HorillaCoreModel):
    project = models.ForeignKey(
        "Project",
        related_name="project_collaborators",
        on_delete=models.CASCADE,
    )


    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
    )


    project_access = models.CharField(
        max_length=20,
        choices=ACCESS_LEVEL_CHOICES,
    )

This model becomes the bridge between a Project and the users who have additional access to it.

You can then implement the same access hooks on Project.

Start with ownership

First, tell Horilla which field represents ownership:

OWNER_FIELDS = [“owner”]

If your model uses a different field, use that instead.

For example:

OWNER_FIELDS = [“project_manager”]

The important thing is that the field points to the user who owns the record.

Define the access levels

Next, decide what access levels your collaborators should have.

For example:

TEAM_ACCESS_LEVELS_FOR_ACTION = {
    "view": ["read", "edit", "owner"],
    "change": ["edit", "owner"],
    "delete": ["edit", "owner"],
}

You can change these rules according to your application’s requirements.

Maybe your application should allow an edit member to update a Project but not delete it.

In that case:

TEAM_ACCESS_LEVELS_FOR_ACTION = {
    "view": ["read", "edit", "owner"],
    "change": ["edit", "owner"],
    "delete": ["owner"],
}

The mapping gives you a simple place to define the business rules.

Add the granted-access filter

The Project model can then implement:

@classmethod
def granted_access_filter(cls, user, action):
    levels = cls.TEAM_ACCESS_LEVELS_FOR_ACTION.get(action)

    if not levels:
        return models.Q(pk__in=[])

    return models.Q(
        project_collaborators__user=user,
        project_collaborators__project_access__in=levels,
    )

Because the collaborator model uses:

related_name=”project_collaborators”

the Project queryset can traverse that relationship directly.

Add the object-level check

Then add:

def has_granted_access(self, user, action):
    levels = self.TEAM_ACCESS_LEVELS_FOR_ACTION.get(action)

    return bool(levels) and self.project_collaborators.filter(
        user=user,
        project_access__in=levels,
    ).exists()

Now the model supports both kinds of access checks:

granted_access_filter()

for querysets, and:

has_granted_access()

for individual objects.

There is no registration step

One of the nicest parts of this design is that you don’t need to register the model anywhere.

You don’t need something like:

register_granted_access(Project)

The generic layer simply checks whether the expected methods exist.

So implementing:

granted_access_filter()

and:

has_granted_access()

is enough to opt the model into the system.

This keeps the architecture flexible and avoids creating another central registry that every module has to maintain.

Don’t forget the Django permissions

There is one easy mistake to make here.

Implementing the granted-access methods doesn’t bypass Django permissions.

Your Project users still need the appropriate permission.

If the model uses Django’s normal default permissions, Horilla’s signal can create the corresponding “own” permissions, such as:

view_own_project
change_own_project
delete_own_project

But creating the permissions isn’t the same as assigning them.

The relevant Roles still need to receive those permissions.

Otherwise, Layer 1 will reject the request before the record-level access logic is even considered.

Think of it like this:

Django permission
↓
“Can you perform this action?”
↓
Record-level access
↓
“Can you perform it on this record?”

Both checks matter.

Update the row action configuration

If you want team members to see Edit or Delete actions, you should also update the corresponding view configuration.

Instead of:

“owner_field”: “owner”

use the appropriate method:

“owner_method”: “is_change_granted”

or:

“owner_method”: “is_delete_granted”

You can define the convenience methods on your model like this:

def is_view_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “view”)

def is_change_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “change”)

def is_delete_granted(self, user):

    return self._is_owner(user) or self.has_granted_access(user, “delete”)

This gives the view layer a clean interface without making it aware of how collaboration works internally.

Check hardcoded permission decorators too

There is another place that can easily be missed.

Some views may have a hardcoded permission requirement such as:

permission_required_or_denied(
[“app.delete_project”]
)

If the view only accepts the blanket permission, users who have:

delete_own_project

may never reach the record-level access check.

For a model using own permissions, you generally want the view to accept both:

permission_required_or_denied(
[
“app.delete_project”,
“app.delete_own_project”,
]
)

The exact implementation depends on the view, but the principle is the same:

Don’t block the request before record-level access gets a chance to make its decision.

The UI still needs a way to manage collaborators

The access system doesn’t create team members by itself.

If there is no ProjectCollaborator record, there is nothing for has_granted_access() to find.

So the feature also needs a management interface.

For example, users should be able to:

  • Add a collaborator to a Project
  • Select their access level
  • Change their access level later
  • Remove them from the Project
  • View the current collaborators

For Opportunity, the team management UI is a useful reference. The same pattern can be followed for a Project collaborator form and list view.

This is an important distinction: the generic access system handles enforcing access, but your module is responsible for providing the UI that creates and manages the grants.

Where the generic system automatically applies

Once the model implements the hooks, the access logic can be used across the generic layer without adding special code to every view.

That includes places such as:

  • Standard list views
  • Global search
  • Detail views and tabs
  • Kanban views
  • Single-record delete operations
  • Form edit handling
  • Bulk update operations
  • Bulk delete operations

This is one of the biggest advantages of putting the access logic in the generic layer.

The Opportunity model doesn’t need to teach every individual view how team access works.

The model simply exposes the access rules, and the generic layer consumes them.

One important limitation: calendar and dashboard queries

There is currently an important gap to be aware of.

Horilla also has a simpler helper:

get_queryset_for_module()

This helper is used in places such as calendar views and dashboard widgets.

Unlike the main HorillaListView.get_queryset() path, it doesn’t currently apply granted_access_filter().

That means the same Opportunity can behave differently depending on where it is displayed.

For example, an Opportunity team member with edit access may correctly see an Opportunity in:

  • the normal Opportunity list
  • search
  • detail pages

but the same Opportunity may not be included when records are scoped through get_queryset_for_module().

The reason is that this helper still relies on OWNER_FIELDS without adding the granted-access filter.

If you’re implementing team access for a new model and that model is also used by calendar or dashboard functionality, this is something you’ll need to account for.

The fix is conceptually straightforward: extend get_queryset_for_module() so that it also obtains the model’s granted_access_filter() and combines it with the ownership filter.

In other words, the helper needs to understand the same two sources of record access:

ownership
+
explicit grants

Without that, record-level access will only be consistent in the generic views that already support the hook.

Why this design works well

There is a nice architectural idea behind all of this.

The generic layer doesn’t need to understand what “team access” means.

It doesn’t need to know whether a model has:

OpportunityTeamMember

or:

ProjectCollaborator

or:

AccountMember

or some completely different relationship.

Each model owns its own business rules.

The generic layer only needs a small contract:

granted_access_filter(user, action)

for queryset access, and:

has_granted_access(user, action)

for individual objects.

That keeps the generic code generic while allowing each application model to define its own access rules.

It also means you aren’t forced into a single collaboration model across the entire CRM.

One model might use three access levels.

Another might use two.

Another might grant access based on a completely different relationship.

As long as it exposes the expected methods, the generic system can work with it.

Record-level permissions are what turn a basic role-based permission system into something that actually fits a CRM.

Django can tell you that a user is allowed to edit an Opportunity. Horilla’s ownership and granted-access system takes the next step and determines which Opportunities that permission applies to.

The important part is that these responsibilities remain separate.

Django permissions answer:

“What can this user do?”

OWNER_FIELDS answers:

“Which records belong to this user?”

And the granted-access hooks answer:

“Which additional records has this user been given access to?”

The Opportunity team implementation is a good example of how those pieces can work together. A user can remain a normal team member without becoming the actual owner, while still receiving exactly the level of access they need.

More importantly, the design is reusable. If you add granted_access_filter() and has_granted_access() to another model, the generic layer can automatically make use of them across many views and operations.

The main thing to remember when implementing this yourself is that record-level access isn’t a replacement for Django permissions. It’s an additional layer that makes those permissions more precise.

Once you think of access as permission first, record scope second, the whole architecture becomes much easier to understand — and extending it to new Horilla modules becomes much less complicated.

Share this article