Skip to main content

Model Permissions

The Model Permissions package allows developers to protect specific Django models by enforcing permission checks on authenticated users when they attempt to perform actions (create, read, update, delete) on the model. This is achieved without requiring developers to write extra code in their app's views (e.g. to check for permissions via conditional statements).

To provide developers with flexibility when defining their app's access control model, Model Permissions can be configured to check for permissions at either the object level or the model level.

Object vs model permissions

Django allows permissions to be assigned at either the object level or model level.

Object-level permissions means that a user is allowed to perform read, update, or delete operations on a single instance of a model class. This is akin to giving a user permissions over a single record in a database table.

Model-level permissions means that a user is allowed to perform create, read, update, or delete operations on any instance of a model class. This is akin to giving a user permissions over an entire database table containing a particular type of record. Note that "create" permission can only be given over the entire model class, since it makes no sense to allow a user to "create" a specific object that already exists.

Installation

The Model Permissions package can be installed using the command below:

pip install govtech-csg-xcg-modelpermissions

Source code for the package can be found at https://github.com/GovTech-CSG/govtech-csg-xcg-modelpermissions.

Setup

Model Permissions depends on the django-crum and django-guardian packages, which should have been installed automatically if you installed govtech-csg-xcg-modelpermissions using pip.

To set up the Model Permissions functionality, add the following items to the INSTALLED_APPS, MIDDLEWARE, and AUTHENTICATION_BACKENDS settings in your project's settings.py file:

./<PROJECT_NAME>/settings.py
INSTALLED_APPS = [
# ...
'guardian',
'govtech_csg_xcg.modelpermissions',
'your_app' # This refers to your own app
]

MIDDLEWARE = [
# ...
'crum.CurrentRequestUserMiddleware'
]

AUTHENTICATION_BACKENDS = [
'django.contrib.auth.backends.ModelBackend', # This will be in the list by default, leave it there
'guardian.backends.ObjectPermissionBackend'
]

After modifying these settings, create and run a database migration to apply changes required by the guardian app:

python manage.py makemigrations
python manage.py migrate

Logging

The package uses its own logger with the name modelpermissions. By default, this logger uses a stream handler and emits messages with level INFO and above. The log message format looks something like this:

2023-06-22 09:50:43,348 [INFO][XCG][modelpermissions]: This is an informational message.

To safely override the default logging configuration, you can write code in settings.py to import the logger from the package and modify it directly. The example below sets the logger's level to DEBUG:

settings.py
from govtech_csg_xcg.modelpermissions import logger
import logging

logger.setLevel(logging.DEBUG)

For more information on the Python logging library, see the official documentation.

Usage

Basics

To enable permissions checks for a model class, decorate it with the orm_default_permissions_check decorator.

./<APP_NAME>/models.py
from govtech_csg_xcg.modelpermissions.decorators import orm_default_permissions_check

@orm_default_permissions_check
class Book(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE)
title = models.TextField()

This makes it such that for any request attempting to perform any operation (i.e. create, read, update, or delete) on the model, modelpermissions checks if the User object tied to the request has the required permissions. By default, modelpermissions checks for permissions at the object level (refer to the section below for how to change this).

caution

Note that modelpermissions can only check for the default permissions generated by django.contrib.auth, which are "add", "change", "delete", and "view". Custom permissions are not supported at the moment.

If the user does not have the required permissions, modelpermissions will raise an exception which will cause Django to return a 403 Forbidden error response.

Assigning object-level permissions

modelpermissions does not handle the assignment of permissions to users. However, it provides some helper functions to aid the developer in assigning object-level permissions, which can be imported from the module govtech_csg_xcg.modelpermissions.shortcuts. The list of helper functions are:

  1. get_model_permissions(model)

    • Arguments:

      • model: A model class - i.e. a class in models.py that inherits from django.db.models.Model
    • Returns:

      • A dictionary mapping the keys "create", "read", "update", and "delete" to their fully-qualified permission name for the Django model. For example:

        {
        'create': 'app.add_book',
        'read': 'app.view_book',
        'update': 'app.change_book',
        'delete': 'app.delete_book',
        }
  2. Shortcuts from the django-guardian package, with a thin wrapper around them to remove permissions checks for these specific functions. Refer to django-guardian's official docs for the API reference:

    • assign_perm
    • remove_perm
    • get_perms
    • get_user_perms
    • get_group_perms
    • get_perms_for_model
    • get_users_with_perms
    • get_groups_with_perms
    • get_objects_for_user
    • get_objects_for_group

A basic example of how to assign permissions is shown below:

./APP_NAME>/views.py
from govtech_csg_xcg.modelpermissions.shortcuts import get_model_permissions, assign_perm

from .models import Book


def add_permissions_for_specific_book(request, book_title):
# Get the specific book object from the DB
specific_book = Book.objects.get(title=book_title)

# Get the mapping of CRUD permissions for the Book model
perms = get_model_permissions(Book)

# Assign the requesting user CRUD permissions over this specific book instance
assign_perm(perms['read'], request.user, instance)
assign_perm(perms['update'], request.user, instance)
assign_perm(perms['delete'], request.user, instance)

Changing from object-level to model-level checks

As mentioned above, the permission checks are performed at the object level by default, which means that the user needs to have permissions over a specific instance of the model (i.e. a single record in the database). If you would like the check to be performed at the model level (i.e. user is assigned permissions over the entire model class), you can add the setting XCG_RBAC_OBJECT_CONTROL = False to your project's settings.py file.

Assignment of object-level permissions

If you change to model-level permissions checking, the shortcuts for assigning object-level permissions can no longer be used. You should instead follow the instructions provided in the section on assigning model-level permissions.

Assigning model-level permissions

If object-level permissions checking is disabled, permissions can be assigned using Django's built-in permissions API. A simple example is provided below:

from django.contrib.auth.models import Permission, User

read_perm = Permission.objects.get(codename='view_librarybook')
user = User.objects.get(username='username')
user.user_permissions.add(read_perm)

Django provides more sophisticated options for assigning permissions, including assigning permissions to groups of users rather than a single user. For more information on these options, refer to the official Django documentation.

Disabling permissions checks for specific code

Permissions checks can be disabled for specific pieces of code by using either the with_sudo function decorator or the sudo context manager provided in govtech_csg_xcg.modelpermissions.shortcuts. For example:

./<APP_NAME>/views.py
from govtech_csg_xcg.modelpermissions.shortcuts import sudo, with_sudo


@with_sudo
def no_permissions_check_decorator(request):
# All the code in this function will not be subject to permissions checks,
# even if it tries to access an object whose model class is decorated with
# orm_default_permissions_check.
do_something_to_a_model()


def no_permissions_check_context_manager(request):
# All the code in this block will not be subject to permissions checks,
# even if it tries to access an object whose model class is decorated with
# orm_default_permissions_check.
with sudo():
do_something_to_a_model()
caution

The sudo and with_sudo functions are very powerful as they allow all operations on all model objects. We recommend using them only when necessary and with caution.

Impersonating a specific user

The django-crum package, which comes installed together with modelpermissions, provides a helper function impersonate that allows code to run in the context of a specific user. This may be useful for testing purposes, e.g. to troubleshoot why a specific user can/cannot perform certain actions.

from crum import impersonate

# Where specific_user is a django.contrib.auth.models.User object
with impersonate(specific_user):
do_something_to_a_model()

For more information on the functionality offered by django-crum, refer to the official documentation.

caution

As the impersonate function can potentially execute code in the context of highly-privileged users, we do not recommend using it in a production setting.

Using a custom template for 403 Forbidden error responses

As mentioned in the basic usage section, modelpermissions will cause Django to return a 403 Forbidden error response if a user without proper permissions accesses a model object. By default, Django will search for a template named 403.html in ./<APP_DIR>/templates/ across all your installed applications. In the absence of such a template, Django will return a page with just the text "403 Forbidden".

To specify a custom template for 403 errors generated by modelpermissions

  1. Add the middleware govtech_csg_xcg.modelpermissions.middleware.ModelPermissionsMiddleware to your MIDDLEWARE list in settings.py
  2. Provide a path to the template via the setting XCG_RBAC_403_TEMPLATE in settings.py. This path should be relative to your app's templates directory - i.e. XCG_RBAC_403_TEMPLATE = 'errors/forbidden.html' will cause Django to look for the template named forbidden.html in <PROJECT_ROOT_DIR>/<APP_DIR>/templates/errors/, across all your installed apps.
./<PROJECT_NAME>/settings.py
MIDDLEWARE = [
# ...
'govtech_csg_xcg.modelpermissions.middleware.ModelPermissionsMiddleware'
]

XCG_RBAC_403_TEMPLATE = 'errors/forbidden.html'