Access control in Django Applications¶
This page documents a common convention for access control in Django applications with particular emphasis on APIs driven by the Django REST Framework.
The attribute-based access control model¶
We try to think of access control within the framework of an attribute-based access control (ABAC) model. In the ABAC model:
- Users and objects have a context associated with them which are a set of attributes. The primary context for users will be zero or more roles although application-specific context is likely.
- A role is a set of atomic permissions. Roles are given names relating to business needs such as "Meeting room booker", "Director of Studies" or "Principal Investigator".
- A permission grants the ability to perform a specific actions. For example, "List all meetings", "Create interview score for applicants to their affiliated college" or "Add new expenditure to owned grants".
- A permission always grants an ability but the scope may be limited by context.
- The scope of a permission depends on the context.
Example¶
As a specific example, suppose we are implementing an API which allows users to schedule meetings for teams. Some users can see all scheduled meetings, some can see meetings scheduled for their teams, some can schedule meetings within their teams and some can schedule meetings for everyone.
In our example, each user's context is the list of teams they are a member of.
For our application, there are four base permissions:
meetings.add_meeting
- can schedule meetings for anyone;meetings.add_meeting_for_team
- can schedule meetings for their team;meetings.view_meeting
- can see all meetings scheduled for the future;meetings.view_meeting_for_team
- can see scheduled meetings with their teams.
Some permissions, like meetings.add_meeting
, are global: they relate to all
objects. Some permissions, like meetings.add_meeting_for_team
, are
object-specific in that they take into account attributes from a specific
object as part of the context.
We usually want global permissions to exist but they almost always will be granted only to admins or superusers. Ordinary users will usually be given permissions which are context sensitive.
Note
In Django, permissions may have any name but we try to align with the convention used by the default permission set.
There are three roles in our example application:
- "Read-only admin" - will have the
meetings.view_meeting
permission. - "Team member" - will have the
meetings.view_meeting_for_team
andmeetings.add_meeting_for_team
permissions. - "Super-user" - will have all permissions.
A user's context is likely to be extremely application specific but we use the generic Django Group model as a model for roles since groups in Django can be assigned permissions.
Question
At the moment management of groups membership is performed manually in the Django admin. There is scope to provide a more automated mechanism for this which includes Lookup groups. Any solution will also have to deal with the possibility of service account users as well. See the section below on integrating Lookup.
Finally we have some access control based purely on the attributes of the meeting: meetings which were scheduled in the past can never be viewed.
Designing permissions¶
Role permissions should always be additive. That is to say that they should grant additional rights, never remove existing ones. This simplifies permission checking because we can early out if a given permission ever allows an action without checking further permissions.
In the case of view_...
-style permissions it is also important that the
permission be additive. This means that the set of objects a user can view can
be queried from the database by logical OR-ing all of the conditions implied by
the permissions together. We'll see that below in the case of the
view_meeting
and view_meeting_for_team
permissions.
Pure attribute-based controls, on the other hand, can be restrictive if they encode fundamental business logic. For example we included the restrictive access control that meetings scheduled in the past cannot be viewed. Attribute-based controls like these which are independent of user attributes shouldn't be modelled via permissions which can be granted to roles. They should instead be modelled as restrictions which form part of the core application logic.
Implementation in Django REST Framework¶
Let's consider how we implement the simple meeting room booking example we outlined above. We will cover creating the role groups, creating the permissions and checking for access in the Django REST Framework itself.
We'll assume that there are Meeting
and Team
models which already exist and
that a relationship has been added so that user.teams
lists the Team
-s
which a user
is a member of.
Creating the groups and permissions¶
The Django groups and permissions system is a little bit special and so it is
generally recommended that one ensure permissions and groups exist via a
post_migrate
signal configured in the applications AppConfig
:
from django.apps import AppConfig
from django.contrib.auth.models import Group, Permission
from django.db.models.signals import post_migrate
def ensure_permissions_and_groups(sender, **kwargs):
# Get appropriate permission models, creating non-default ones if necessary.
view_meeting = Permission.objects.get(code_name="view_meeting")
view_meeting_for_team, _ = Permission.objects.update_or_create(
code_name="view_meeting_for_team",
defaults={"name": "View meetings with their teams"}
)
add_meeting_for_team, _ = Permission.objects.update_or_create(
code_name="add_meeting_for_team",
defaults={"name": "Add meetings with their teams"}
)
# Note: superusers will have the "add_meeting" permission implicitly.
# Ensure groups exist with appropriate permissions.
Group.objects.get_or_create(name="Read-only admin").permissions.set([view_meeting])
Group.objects.get_or_create(name="Team member").permissions.set([
view_meeting_for_team, add_meeting_for_team
])
class MeetingsAppConfig(AppConfig):
def ready(self):
post_migrate.connect(ensure_permissions_and_groups, sender=self)
Adding permission checking logic¶
We use the DRY REST Permissions library to specify permission handling logic. This library allows permissions check logic to be specified on the model.
For example, to implement our access control scheme, we add some special
methods to the Meeting
model:
import datetime
# ...
class Meeting(models.Model):
# ... model fields ...
@staticmethod
def has_read_permission(request):
# We defer to has_object_read_permission().
return True
def has_object_read_permissions(self, request):
# If the meeting was in the past, the user can never read it even if
# they are a super-user. This is a pure attribute based access control.
if self.scheduled_at < datetime.datetime.utcnow():
return False
# If the user has view_meeting, they can see all meetings.
if request.user.has_perm("meetings.view_meeting"):
return True
# If the user has view_meeting_for_team and the meeting is with their
# team, they have permission.
if (request.user.has_perm("meetings.view_meeting_for_team")
and request.user.teams.filter(id=self.team.id).exists()):
return True
# Otherwise, permission is refused.
return False
@staticmethod
def has_create_permission(request):
# Checking if a specific meeting create request is allowed is performed
# in MeetingSerializer. This is necessary since before a meeting is
# created, there is no object which could be passed to a hypothetical
# has_object_create_permission() method.
# These are all permissions which *may* allow meeting creation.
return any(request.user.has_perm(p) for p in ["meetings.add_meeting", "meetings.add_meeting_for_team"])
The meeting serializer has two permissions-related bits of functionality. We
add a get_queryset()
method which looks at the various view_meeting_...
permissions the user has and builds up a list of all the meetings they can see.
As part of the serializer's create()
method we also check if the user is
allowed to create the meeting.
import datetime
from django.db.models import Q
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from .models import Meeting, Team
class MeetingSerializer(serializers.ModelSerializer):
class Meta:
model = models.MeetingSerializer
fields = ["id", "team_id"]
def get_queryset(self, request):
"""
Return a queryset representing all the meetings the user can at least *view*.
"""
user = request.user
# Build up a set of conditions for the queryset based on the
# permissions the user has. These conditions are OR-ed together since
# permissions are additive.
qs_conditions = []
# If the user has "view_meeting", they can see all meetings.
if user.has_perm("meetings.view_meeting"):
# The following is a special "always true" Q-object.
qs_conditions.append(~Q(pk__in=[]))
if user.has_perm("meetings.view_meeting_for_team"):
# The meeting should be with one of the user's teams.
qs_conditions.append(Q(team__in=user.teams))
# If the user has no view-related permissions, return an empty queryset.
if len(qs_conditions) == 0:
return Meeting.objects.none()
# Otherwise, let's get all the objects the user can view. Recall that we
# have an attribute-based control which says that meetings scheduled in
# the past cannot be viewed and so we start with a filtered view.
qs = Meeting.objects.filter(scheduled_at__gte=datetime.datetime.utcnow())
# Return a queryset which is the result or OR-ing all the objects
# covered by the permissions.
overall_filter = qs_conditions[0]
for condition in qs_conditions[1:]:
overall_filter = overall_filter | condition
return qs.filter(overall_filter)
def create(self, validated_data):
# Check object-specific create permission.
self._check_create_permission(validated_data)
# Defer to ModelSerializer's create() method.
return super().create(validated_data)
def _check_create_permission(self, validated_data):
"""Raise PermissionDenied if the user should not be able to create the meeting."""
user = self.context['request'].user
# Check for global create permissions.
if user.has_perm("meetings.add_meeting"):
return
# Check for team create permissions and that the meeting is for one of the user's teams.
if (user.has_perm("meetings.add_meeting_for_team")
and user.teams.filter(id=validated_data["team_id"]).exists()):
return
# Otherwise, raise an exception.
raise PermissionDenied({"status": "User does not have permission to create meeting"})
To make use of the DRY REST Permissions framework we also have to wire it in to the appropriate view:
from rest_framework import viewsets
from dry_rest_permissions.generics import DRYPermissions
from .models import Meeting
from .serializers import MeetingSerializer
class MeetingViewSet(viewsets.ModelViewSet):
permission_classes = (DRYPermissions,)
serializer_class = MeetingSerializer
def get_queryset(self):
return MeetingSerializer.get_queryset(self.request)
Letting API clients know what permissions they have¶
It is useful when making User Interfaces (UIs) for the UI to know what permissions the signed in user actually has. UIs will usually hide portions of themselves related to functionality which the user cannot perform.
The DRY REST Permissions framework has a built in solution for this which can
be used to show what permissions a user has on a given object. To make use of
this, add a permissions
field to the serializer. For example:
from dry_rest_permissions.generics import DRYPermissionsField
# ...
class MeetingSerializer(serializers.ModelSerializer):
permissions = DRYPermissionsField()
# ... etc ...
Then a permissions
field will be rendered on the response like the following:
{
"permissions": {
"read": true,
"write": false,
"create": true,
"update": true
},
// Other fields...
}
Integrating Lookup¶
The use of Django groups is convenient since groups can natively have
permissions associated with them. Although Lookup provides membership
information for groups, it does not have a built in way of providing a record
of permissions in the same way the Django Group
model does.
It is tempting to use a Lookup group directly as a permission, perhaps by using some of the helper functions from the django-ucamlookup module. However this confuses the use of groups as a means to defined roles with a means to define permissions. Essentially one would be forcing Lookup groups to be roles with one single permission and, worse, that permission is hard-coded.
We do not currently have a production-tested solution for this. Those looking to experiment may look at the Django LDAP sync module which allows Django group membership to be synchronised from Lookup using Lookup's LDAP personality.
Non-Django based approaches¶
Although this section focuses on the use of Django's permission framework, some applications may require that the access control rules be modifiable by a different group. For situations like that it may be wise to consider an external policy agent. Policy agents are APIs which decouple the access control policy from the application. Applications make an API request to the policy agent with information on the action requested by the user and the context of the action. The policy agent then has its own ABAC engine which approves or denies the request.
The API Gateway project did some initial experimentation with Open Policy Agent which suggested that it may have potential for products which need this specialist access control.
Things to avoid¶
This section discusses some common "gotchas" which should be avoided.
Not writing tests¶
There should be tests for every fine-grained permission. The tests should check both that users with the permission can perform the action and users without the permission cannot.
There should be tests for every group. It is harder to be comprehensive with this but the tests should at least check that actions allowed by the group's permissions can be performed and those not explicitly allowed cannot.
Hard-coding groups¶
Note that the concept of a "role" or, in Django terms, a "group" is completely invisible to the code actually implementing the permission checking. This is important. Groups and roles are related to the roles that users may have within an application. The scope, number and nature of roles can change often as requirements are refined.
In particular, stakeholders love to add more classes of "admins" which can see or modify subsets of the application.
The mapping from roles into permissions is where the business roles of different system users are defined and roles may be broad or extremely narrow.
Similarly, keeping permissions small and well defined means it is easier to build flexible roles out of collections of permissions. It is also to easier to both test and review permission handling code if the permissions are fine-grained.
Not documenting the access control policy outside of the code¶
Although the post_migrate
hook above did at least document the non-default
permissions which were being created, it is important that the list of
permissions and group permissions are documented outside of the code so that
they can be reviewed by non-technical stakeholders.
Even allowing for the post_migrate
hook to act as documentation, it only
describes the roles and permissions. It did not capture the attribute based
access control for meetings from our example.
We should document the full set of roles, permissions and non-user attribute-based access control policies outside of your code.
Adding permissions to users¶
Occasionally it may become necessary to grant an individual user just a little more privilege than their role allows. It is tempting in this case to assign an additional permission to the user in the Django admin. This is a dangerous practice because that means that the permission is "stealthy"; it is all to easy to forget the permission has been added.
Instead think about the reasons why the user needs additional permissions and rephrase it as a modified role.
Managing group permissions manually¶
It is very tempting to give stakeholders rights in the Django admin to directly
modify group permissions. This is dangerous since the post_migrate
hook will
reset the permissions next time it is run. This may happen days, weeks or
months after the group's permission list was changed.
View a group's permission list as part of the database schema and not as something to be configured as part of the application.
Summary¶
In this document we have provided an example of attribute-based access control (ABAC) in Django REST Framework. In our example, users could have blanket permission to add and/or create meetings or could have that permission restricted to their specific teams.