Skip to main content

Part 5 - Limit Responder Privileges

Our issues submission website is now pretty much fully functional. However, in the current state, all responders can view and edit all issues. While this may seem convenient, it actually exposes responders to more information than they need, and goes against the security principle of least privilege.

In this part of the tutorial, we will implement a least-privilege permissions model by assigning responders to specific issue types (e.g. road, facilities) and restricting access such that they can only view and modify issues belonging to their assigned type(s). We will leverage on the XCG package called Model Permissions in order to achieve this.

Creating different groups of responders

There are various ways for us to assign a responder to an issue type. For this tutorial, we will leverage on Django's built-in authentication system to create groups, where each group corresponds to an issue type. The issue type(s) a responder can handle is then determined by his/her group membership(s).

Let's begin by creating our groups and some test responders. We will create 4 groups, 1 for each issue type ("Facilities", "Road", "Sewerage", and "Others"), along with 6 users, 1 for each issue type, 1 special user who is responsible for 2 different issue types, and 1 "manager responder" who should be able to access all issues. To simplify things, we will perform the user and group creation using a Python script.

Create a file named create_groups_and_users.py inside the outer towncouncil folder (the one that contains manage.py) and copy in the code below:

towncouncil/create_groups_and_users.py
from django.contrib.auth.models import Group, User


manager_responder = User.objects.create_user(username="ManagerResponder", password="password")
print("Created responder 'ManagerResponder'\n")

issue_types = ("Facilities", "Road", "Sewerage", "Others")
for issue_type in issue_types:
group = Group.objects.create(name=issue_type)
print(f"Created group '{group.name}'")

responder = User.objects.create_user(username=issue_type + "Responder", password="password")
responder.groups.add(group)
print(f"Created responder '{responder.username}' and added to group '{group.name}'")
manager_responder.groups.add(group)
print(f"'ManagerResponder' added to group '{group.name}'\n")

special_responder = User.objects.create_user(username="FacilitiesRoadResponder", password="password")
print("Created responder 'FacilitiesRoadResponder'")
for group in Group.objects.filter(name__in=("Facilities", "Road")):
special_responder.groups.add(group)
print(f"Added 'FacilitiesRoadResponder' to group '{group.name}'")

Now save the file and run the command below from inside the same towncouncil folder:

python3 manage.py shell < create_groups_and_users.py

You should see the following output on your command line:

Created responder 'ManagerResponder'

Created group 'Facilities'
Created responder 'FacilitiesResponder' and added to group 'Facilities'
'ManagerResponder' added to group 'Facilities'

Created group 'Road'
Created responder 'RoadResponder' and added to group 'Road'
'ManagerResponder' added to group 'Road'

Created group 'Sewerage'
Created responder 'SewerageResponder' and added to group 'Sewerage'
'ManagerResponder' added to group 'Sewerage'

Created group 'Others'
Created responder 'OthersResponder' and added to group 'Others'
'ManagerResponder' added to group 'Others'

Created responder 'FacilitiesRoadResponder'
Added 'FacilitiesRoadResponder' to group 'Facilities'
Added 'FacilitiesRoadResponder' to group 'Road'

After running the script, we should now have the following users who belong to the following groups:

  • "FacilitiesResponder" belonging to the "Facilities" group
  • "RoadResponder" belonging to the "Road" group
  • "SewerageResponder" belonging to the "Sewerage" group
  • "OthersResponder" belonging to the "Others" group
  • "FacilitiesRoadResponder" belonging to both the "Facilities" and "Road" groups
  • "ManagerResponder" belonging to all the groups

All the users have their passwords set as "password" for the purpose of simplicity (NOTE: please DO NOT use such simple passwords in a production setting).

You can now start the development server and try to log in as each of the users above. At the moment, although they have been assigned to groups, we have yet lock down the permissions for our Issue objects, so you should still be able to browse all issues with any one of the users above.

Restricting access using Model Permissions

With our groups and test users created, we are now ready to incorporate the XCG Model Permissions package.

In Django, database operations performed via the Model API (e.g. Issue.objects.get()) are allowed by default. As long as a user has permission to access a view that performs model operations, no further checks are carried out at the model level. To give a concrete example, let's look at our current display_issue view function:

towncouncil/issues/views.py
@login_required
def display_issue(request, issue_id):
issue = get_object_or_404(Issue, pk=issue_id)

if request.method == "POST":
new_status = request.POST.get("issue_status")
if new_status != issue.status:
Issue.objects.filter(pk=issue_id).update(status=new_status)
return redirect("display_issue", issue_id=issue_id)
else:
return render(request, template_name="display_issue.html", context={"issue": issue})

The only requirement to query the database for a specific issue (see highlighted line) is that the user is logged in. This implies that as long a logged-in user knows the ID of another issue, they can access that issue even if they are not assigned to the particular issue type. What we ideally want is a way to assign and enforce permissions at the individual object level, so that even if a logged in user accesses this view for an issue that doesn't belong to them, they will encounter an error when querying the database. This is where Model Permissions comes in.

Model Permissions provides a decorator that can be applied to any model class. Once decorated, any operations carried out on the model will fail unless the user is authenticated and has been explicitly assigned the appropriate permissions. To make things more concrete, let's see this in action. First, let's install the Model Permissions package using by running the command below:

python3 -m pip install govtech-csg-xcg-modelpermissions

Next, we have to set up Model Permissions by tweaking our project's configurations. Add the highlighted lines below to INSTALLED_APPS and MIDDLEWARE within your settings.py file:

towncouncil/towncouncil/settings.py
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'issues',
'govtech_csg_xcg.viewpermissions',
'guardian',
'govtech_csg_xcg.modelpermissions',
]

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'govtech_csg_xcg.securefileupload.middleware.FileUploadValidationMiddleware',
'govtech_csg_xcg.viewpermissions.middleware.ViewPermissionsMiddleware',
'crum.CurrentRequestUserMiddleware',
]

Additionally, if it doesn't already exist, add a new setting called AUTHENTICATION_BACKENDS at the end of your settings.py file and ensure that it contains the entries shown below:

towncouncil/towncouncil/settings.py
AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend',
'guardian.backends.ObjectPermissionBackend',
]

As a final setup step, run a database migration to synchronize some database changes that Model Permissions requires in order to work (you should run these commands inside the towncouncil folder containing manage.py):

python3 manage.py makemigrations
python3 manage.py migrate

Now we can use Model Permissions to enforce permissions checks for the Issue model. To do this, open the models.py file inside your issues folder and add the following highlighted lines near the top of the file:

towncouncil/issues/models.py
from django.db import models
from django.utils import timezone
from django.core.validators import RegexValidator
from govtech_csg_xcg.modelpermissions.decorators import orm_default_permissions_check
from govtech_csg_xcg.securefileupload.validators import xcg_file_validator
from govtech_csg_xcg.securemodelpkid.model import RandomIDModel


@orm_default_permissions_check
class Issue(RandomIDModel):
# ...the rest of your code

This tells Model Permissions that any attempt to use the Issue model should be subject to a permission check. To verify this, start the development server and try to submit an issue as an unauthenticated user. You should receive a 403 Forbidden error response. As none of our reponder users have been explicitly assigned permissions to any of the issues yet, you should see the same error if you sign in as any responder and try to view either the issues list or a specific issue page.

Superusers and Model Permissions

Super users in Django have all permissions assigned by default, and hence will not be subject to a 403 Forbidden error when Model Permissions is in play, even if no permissions have been explicitly assigned. While super users may be a helpful tool during development, it is not recommended to create any in a production environment due to their overly broad permission set.

Checkpoint

If you are having difficulties following the tutorial up to this point, the full source code and configuration files for the towncouncil project folder can be downloaded using this link. Replace the contents of your outer towncouncil folder (the one containing manage.py) with the extracted contents of the downloaded zip file.

Assigning permissions to responders

Having verified that Model Permissions has been set up properly, we can now move on to assign the appropriate permissions to view or modify issues.

Recall that our responders belong to groups, and each group corresponds to a particular issue type. Since our responders need to be able to view and modify issues to do their job, we will assign "read" and "update" permissions for each individual issue to the corresponding group. As an example, this will result in any responder belonging to the "Road" group having "read" and "update" permissions for every issue of type "Road". To do this, we will use another Python script.

Create a file named assign_permissions.py inside the outer towncouncil folder (the one that contains manage.py) and copy in the code below:

towncouncil/assign_permissions.py
from django.contrib.auth.models import Group
from govtech_csg_xcg.modelpermissions.shortcuts import (
get_model_permissions,
assign_perm,
sudo,
)

from issues.models import Issue


permissions = get_model_permissions(Issue)
with sudo():
for issue_type in ("Facilities", "Road", "Sewerage", "Others"):
print(f"Assigning permissions for issue type '{issue_type}'...")
group = Group.objects.get(name=issue_type)
issues = Issue.objects.filter(type=issue_type)
for issue in issues:
assign_perm(permissions["read"], group, issue)
assign_perm(permissions["update"], group, issue)
print(f"Finished assigning permissions for issue type '{issue_type}'\n")

The script above loops through the all the issue types, getting the appropriate responder group for each type and assigning it "read" and "update" permissions for all issues of that type. To execute the script, run the commands below inside your outer towncouncil folder (the one containing manage.py):

python3 manage.py shell < assign_permissions.py

You should see the following output on your command line:

Assigning permissions for issue type 'Facilities'...
Finished assigning permissions for issue type 'Facilities'

Assigning permissions for issue type 'Road'...
Finished assigning permissions for issue type 'Road'

Assigning permissions for issue type 'Sewerage'...
Finished assigning permissions for issue type 'Sewerage'

Assigning permissions for issue type 'Others'...
Finished assigning permissions for issue type 'Others'

At this point, we have technically assigned permissions to each responder, since they now belong to their respective groups. However, signing in as any responder other than "ManagerResponder" to view the issues list will likely still fail, as long as you have previously created issues of multiple types. This is because when the model API is used to operate on a set of model objects (e.g. via Issue.objects.all()), Model Permissions will check if the triggering user has appropriate permissions over all the objects, not just some. To give an example, if you try to view the issues list as "RoadResponder" and all the issues in your database are of type "Road" except for one which has type "Others", Model Permissions will still return a 403 Forbidden error, because "RoadResponder" does not have permissions to view one the issue with type "Others".

To handle this, we will modify the list_issues view function to retrieve a subset of issues depending on the group(s) a responder belongs to, as opposed to retrieving all issues. Open the views.py file inside your issues folder and replace the list_issues function with the code below:

towncouncil/issues/views.py
@login_required
def list_issues(request):
user_groups = list(request.user.groups.values_list("name", flat=True))
issues = Issue.objects.filter(type__in=user_groups)
return render(request, template_name="list_issues.html", context={"issues": issues})

Now start the development server and visit http://127.0.0.1:8000/issues/, signing in as any test responder of your choice. You should only be able to see issues that belong to the responder's assigned type(s). You can also verify that a responder cannot forcibly navigate to an issue details page for an unassigned issue type. To do this, sign in as the "ManagerResponder" and take note of the issue IDs. Then sign out and back in again as "RoadResponder" (for example) and try to visit the URL path /issues/<id>, replacing <id> with an actual ID for a non-"Road" issue. You should receive a 403 Forbidden error page.

Allowing unauthenticated users to submit issues

We have now successfully assigned permissions to responders and enforced permissions checks on all existing issues. However, we are still faced with a critical issue: We need to allow unauthenticated users to "create" issues in the submit_issue view, but Model Permissions cannot assign permissions to an unauthenticated user.

For such scenarios, Model Permissions provides a special mechanism to bypass the permissions check. Open the views.py file inside your issues folder and modify the code around the submit_issue function to reflect the "after" tab below:

towncouncil/issues/views.py
from django.shortcuts import redirect
from django.http import HttpResponse
from govtech_csg_xcg.securefileupload.decorator import upload_config
from .forms import IssueForm


@public
@upload_config(whitelist_name="CUSTOM", whitelist=["image/png", "image/jpeg"], file_size_limit=3000)
def submit_issue(request):
if request.method == "POST":
form = IssueForm(request.POST, request.FILES)
if form.is_valid():
form.save()
return redirect("thank_you")
else:
form = IssueForm()

return render(request, template_name="submit_issue.html", context={"form": form})

The key thing to note here is the use of the sudo context manager in line 20. sudo is provided by Model Permissions as a way to bypass permissions checks for any code running within its context (i.e. within a with sudo() block). Although it should generally be used for administrative scripting rather than in application code, there are legitimate use cases, such as in this case where our application's business context requires us to allow unauthenticated users to create database records. Since form.save(), which saves the form and creates the Issue object, runs within the sudo context, even an unauthenticated user will be allowed to create issues.

Python context managers

A context manager is a Python object generally used to handle the management of external resources such as files, locks, and network connections. At a high level, a context manager defines the logic required to transparently set up and tear down some sort of "environment" in which application code can operate. For more information on this Python concept, refer to this helpful article.

To ensure that newly created issues can be accessed by the correct responders, the code above (lines 24-28 in "After") also assigns "read" and "update" permissions to the matching responder group for each newly created issue.

Finally, let's verify that everything works as intended. Start the development server and visit http://127.0.0.1:8000/submit/ in your browser. Ensuring that you are not signed in as a responder, try to submit a test issue of any type you prefer; You should be able to do so without errors. Then, log in as a responder assigned to the corresponding type and verify that you can see the newly submitted issue.

Checkpoint

If you are having difficulties following the tutorial up to this point, the full source code and configuration files for the towncouncil project folder can be downloaded using this link. Replace the contents of your outer towncouncil folder (the one containing manage.py) with the extracted contents of the downloaded zip file.