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 rbac
:
# django-rbac/
$ printf "django==3.1.7\n" > requirements.txt
$ pip install -r requirements.txt
$ django-admin startproject rbac .
Modify 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 CommonMiddleware
and 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 rbac
:
$ 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 core
:
# 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 settings.py
:
# 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 User
and UserManager
:
# 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 email
and name
. The field email
must be unique and we use that as our user name. We use a UUID as primary key in all the models we create. We also create our own 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 http://localhost:8000
:
$ 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
Adding tests
Now we can do the very important thing we know every developer must do and add tests for our project. Let's first install pytest
and pytest-django
:
$ printf "pytest\npytest-django\n" > requirements-dev.txt
$ pip install -r requirements-dev.txt
Configure pytest.ini
:
# 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 = "test@example.com"
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:
$ pytest
Conclusion
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
raise ImproperlyConfigured(
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!
Perfect =)