In this short series of articles, I'd like to share how to implement granular, resource-level role-based access control in Django. We'll build a REST API that returns 401s (Unauthorized) for unauthenticated users, 404s for authenticated users not authorized to view given resources, and 403s (Forbidden) for users authorized to view resources but forbidden to perform given actions.
We'll be using vanilla Django without extra frameworks or dependencies throughout. This doesn't mean you shouldn't use publicly available packages, but sticking to pure Django is a good choice when you want to learn things and when you need the most flexibility. Remember, though, that user authentication is one place where you don't want to mess things up: the more code you write, the better test suite you'll need!
In this part one, we'll setup a Django project and app for our REST API. We'll add a custom user model and simple tests. You can find the accompanying code in this repository.
Creating the project and app
To start, let's create a new folder and a virtual environment. I use
pyenv virtualenv to create virtual environments:
$ mkdir django-rbac && cd django-rbac $ pyenv virtualenv 3.8.1 django-rbac-3.8.1 $ pyenv local django-rbac.3.8.1
Let's install Django and create a project called
# django-rbac/ $ printf "django==3.1.7\n" > requirements.txt $ pip install -r requirements.txt $ django-admin startproject rbac .
settings.py so that it only contains the apps and middlewares we need:
# rbac/settings.py INSTALLED_APPS = [ 'rbac.core', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', ]
We'll create the
rbac.core app soon below. We use Django's authentication system for authenticating users, which requires us to include
django.contrib.auth app and
django.contrib.auth.middleware.AuthenticationMiddleware middleware. We also need
django.contrib.sessions app and
SessionMiddleware for managing user sessions. We also include
SecurityMiddleware even if not strictly required.
Our views and models will live under
core app. Let's create such an app and move it under
$ python manage.py startapp core $ mv core rbac/
I move the app under the project to keep everything in one place. I don't expect to be adding any extra apps as multiple apps can lead to problems further down the road.
Let us modify
apps.py in the app to use name
rbac.core instead of
# rbac/core/apps.py class CoreConfig(AppConfig): name = 'rbac.core'
Now let's set everything up for our first endpoint
GET /. Here's a simple view for the app:
# rbac/core/views.py from django.http import HttpResponse def index(request): return HttpResponse("Hello, world. You're at the core index.")
Set up a URL to point to the view:
# rbac/core/urls.py from django.urls import path from rbac.core import views urlpatterns = [ path('', views.index) ]
Setup URLs in project to point to our new app:
# rbac/urls.py from django.urls import include, path urlpatterns = [ path('', include('rbac.core.urls')), ]
The project and app are now setup. Before moving further, let's create a custom model for users.
Custom user model
It's always a good idea in Django to create a custom model for users. It's hard to change the user model later and it isn't that much work to roll our own model. A custom model gives the most flexibility later on.
Let's first explicitly define our authentication backend and the
User model we want to use in
# rbac/settings.py AUTHENTICATION_BACKENDS = ['django.contrib.auth.backends.ModelBackend'] AUTH_USER_MODEL = 'core.User'
Now we create our custom user model in
models.py by defining
# rbac/core/models.py import uuid from django.db import models from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager ) class UserManager(BaseUserManager): def create_user(self, email, name, password=None): """ Create and save a user with the given email, name and password. """ if not email: raise ValueError('Users must have an email address') user = self.model( email=self.normalize_email(email), name=name ) user.set_password(password) user.save() return user class User(AbstractBaseUser): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) email = models.EmailField( verbose_name='email address', max_length=255, unique=True, ) name = models.CharField(max_length=32, blank=False, null=False) is_active = models.BooleanField(default=True) objects = UserManager() USERNAME_FIELD = 'email' REQUIRED_FIELDS = ['name'] def __str__(self): return self.email
I will not try to explain this in detail as you can find a full example here in Django documentation. Our
User has fields
name. The field
UserManager that we can use for creating new users as
User.objects.create_user(email=email, name=name, password=password).
Now let's create our first migration to create the user model in database:
$ python manage.py makemigrations
At this point, you could configure your database. We'll be using
sqlite3 in this article for simplicity. See this article how to configure Django to use Postgres database and how to setup a simple health-check endpoint.
Try it out
Let's run our server:
$ python manage.py runserver
Open another terminal tab and make a request to your server at
$ curl http://localhost:8000 Hello, world. You're at the core index.
Creating service layer
We create a service layer to add decoupling between views and models as suggested in this article. Such a decoupling layer helps to keep models lean and all business logic in one place. Services are also very useful for testing, as we can create resources with services in similar fashion as real users would do.
Let's create services for creating users and finding users:
# rbac/core/services.py import typing from rbac.core.models import User def create_user(email: str, name: str, password: str) -> User: return User.objects.create_user(email=email, name=name, password=password) def find_user_by_email(email: str) -> typing.Optional[User]: try: return User.objects.get(email=email) except User.DoesNotExist: return None
Now we can do the very important thing we know every developer must do and add tests for our project. Let's first install
$ printf "pytest\npytest-django\n" > requirements-dev.txt $ pip install -r requirements-dev.txt
# pytest.ini [pytest] DJANGO_SETTINGS_MODULE = rbac.settings
Let's create a simple test for creating a user:
# tests/test_services.py import pytest from rbac.core.services import create_user, find_user_by_email @pytest.mark.django_db def test_create_user(): email = "firstname.lastname@example.org" name = "Jane Doe" password = "some-clever-password" user = create_user(email=email, name=name, password=password) assert user.email == email found_user = find_user_by_email(email=email) assert found_user == user
Now you can run the test and see it pass:
That concludes Part 1. In the next parts, we'll be adding views and tests for logging in users, preventing unwanted users from seeing resources, and finally adding granular role-based access control. See you later!
Top comments (4)
I can't seem to get pytest to work. The file 'pytest.ini' and the folder 'tests' are both in the root directory -- is that correct? Here's part of the error:
venv/lib/python3.9/site-packages/django/conf/init.py:63: in _setup
E django.core.exceptions.ImproperlyConfigured: Requested setting INSTALLED_APPS, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.
Thanks for raising the issue! Have you checked your directory structure and pytest.ini exactly match the one in the accompanying repository? The github workflow seems to work fine 🤔
Thanks for responding! The error must have been on my end. I moved 'pytest.ini' in and out of the 'tests' folder and now everything works. Thank you very much!