DEV Community

Kimmo Sääskilahti
Kimmo Sääskilahti

Posted on • Originally published at kimmosaaskilahti.fi

Setting up Django REST API with custom user model and tests

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
Enter fullscreen mode Exit fullscreen mode

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 .
Enter fullscreen mode Exit fullscreen mode

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',
]
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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.")
Enter fullscreen mode Exit fullscreen mode

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)
]
Enter fullscreen mode Exit fullscreen mode

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')),
]
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Configure pytest.ini:

# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = rbac.settings
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now you can run the test and see it pass:

$ pytest
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
codebyline profile image
Yumei Leventhal • Edited

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.

Collapse
 
ksaaskil profile image
Kimmo Sääskilahti

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 🤔

Collapse
 
codebyline profile image
Yumei Leventhal

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!

Collapse
 
amirmohamad profile image
Amir-Mohamad

Perfect =)