Building permissions in a Django app in 30 minutes

Nearly every application needs to make sure users only see their own account data and not the data of other people. Many other applications go further and add more detailed permissions, like making some content private and public. We have to get these things right! A mistake in permissions will leak data.

In this post we’ll write a clone of Twitter using Django. Then we’ll implement our Django permissions using Oso, an open source policy engine that’s embedded in your application. You’ll learn how to write a Django app from scratch and how to architect that app to make implementing permissions easy.

We’ll end up with a runnable Django app with a highly readable access control policy:

# Allow anyone to view any public posts.
allow(_actor, "view", post: social::Post) if
    post.access_level = social::Post.ACCESS_PUBLIC;

# Allow a user to view their private posts.
allow(actor: social::User, "view", post: social::Post) if
    post.access_level = social::Post.ACCESS_PRIVATE and
    post.created_by = actor;

This tutorial will walk through:

  • Starting a Django project
  • Creating a custom User model
  • Creating Django models and views to list and create posts
  • Adding Oso to our Django app to implement public & private posts.

The tutorial is self-contained, but if you prefer, you can follow along in the github repo. Read on to see how to build the app step-by-step, or check out the django-oso library documentation for more on using Oso with Django.

Starting our project

To get started, we'll need Python 3 (at least 3.6) installed. Let's start a new directory for our project, and create and activate a virtual environment.

$ mkdir oso-social
$ cd oso-social
$ python -m venv venv
$ . venv/bin/activate

Now, let's install django & django-oso in our virtual environment.

$ pip install django
$ pip install django-oso

Django includes the django-admin tool which has commands for scaffolding django apps & developing them. We can use it to start a new project:

$ django-admin startproject oso_social

This will create a new oso_social directory within our top level directory:

ls -l oso_social
total 392
-rwxr-xr-x   1 dhatch  staff     666 Sep  3 16:14 manage.py
drwxr-xr-x   8 dhatch  staff     256 Sep  3 17:55 oso_social
ls -l oso_social/oso_social
total 32
-rw-r--r--  1 dhatch  staff     0 Sep  3 16:14 __init__.py
-rw-r--r--  1 dhatch  staff   397 Sep  3 16:14 asgi.py
-rw-r--r--  1 dhatch  staff  3219 Sep  3 17:55 settings.py
-rw-r--r--  1 dhatch  staff   798 Sep  3 16:44 urls.py
-rw-r--r--  1 dhatch  staff   397 Sep  3 16:14 wsgi.py

Every Django project is organized into multiple apps. Apps are Python modules that are reusable across projects. The oso_social/oso_social module is the project module, which includes settings & configuration for our oso_social project.

Let's create an app now to contain our database models and views. In the oso_social directory:

$ cd oso_social
$ ./manage.py startapp social
ls -l social
total 56
-rw-r--r--  1 dhatch  staff     0 Sep  3 16:14 __init__.py
-rw-r--r--  1 dhatch  staff   206 Sep  3 16:33 admin.py
-rw-r--r--  1 dhatch  staff    87 Sep  3 16:14 apps.py
-rw-r--r--  1 dhatch  staff   638 Sep  3 17:46 models.py
-rw-r--r--  1 dhatch  staff    60 Sep  3 16:14 tests.py
-rw-r--r--  1 dhatch  staff   394 Sep  3 17:52 urls.py
-rw-r--r--  1 dhatch  staff  1312 Sep  3 17:59 views.py

We just used the manage.py utility, which does the same thing as django-admin, but is aware of the project settings. This allows it to perform project specific tasks, like database operations.

To finish setting up our social app we need to add it to the settings file to make Django aware of it. In oso_social/oso_social/settings.py:

# Search for this setting in the file, and add 'social'.
INSTALLED_APPS = [
    # ... existing entires ...
    'social',
]

Creating our User model

First we are going to create a User model to represent users in our app. This model will be used to store User information in our database. Django has a built-in authorization system that handles things like password management, login, logout, and user sessions. A custom User model provides flexibility to add attributes later to our User.

Let's create one in our oso_social/social/models.py file. Use the following contents:

from django.db import models

from django.contrib.auth.models import AbstractUser

class User(AbstractUser):
    pass

The django.contrib.auth built-in app provides authentication for Django projects. We will use it to jumpstart our authentication setup. To create a custom user model, we simply inherit from AbstractUser. We haven't defined any custom fields, so the class body is empty.

Next, we'll add an entry to our settings file in oso_social/oso_social/settings.py. The settings file configures the Django projects and apps within it. It is a standard Python file, where each configuration entry is stored as a global variable. We will add the following line:

AUTH_USER_MODEL = 'social.User'

This indicates to the django.contrib.auth app to use the User model from the social app for authentication.

Finally, we will create a database migration. Django's built in database migration tool keeps your database schema in sync with the models used in your app. To make a migration, run:

$ ./manage.py makemigrations social

This will create oso_social/social/migrations/0001_initial.py. Now, apply this migration to our database (since we haven't customized our database settings it is a SQLite database by default).

$ ./manage.py migrate

Logging in

Now, let's make sure we have set up our authentication properly and can login. We haven't built any views yet, but we can use the built-in Django admin interface to test out our authorization.

First, add our custom User model to the admin site in oso_social/social/admin.py:

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User

# Register your models here.
admin.site.register(User, UserAdmin)

Then, create a super user using the manage.py command:

$ ./manage.py createsuperuser

This will prompt you for user credentials, which you will then use to login to the admin site.

Let's start our app:

$ ./manage.py runserver

And visit http://localhost:8000/admin. From here, we can login and access the admin interface.

Building%20permissions%20in%20a%20Django%20app%20in%2030%20Minutes%204b3da60512dd49e58b9f8e0cbdfbba6d/Screen_Shot_2020-09-10_at_4.14.59_PM.png

Creating our Post model

Now that we've setup our login, let's start building the post functionality. Our app will allow users to create new posts, and view a feed of posts. To support this, we will create a Post model that stores the post information in our database.

In oso_social/social/models.py, create a Post model below the User model:

class Post(models.Model):
    ACCESS_PUBLIC = 0
    ACCESS_PRIVATE = 1
    ACCESS_LEVEL_CHOICES = [
        (ACCESS_PUBLIC, 'Public'),
        (ACCESS_PRIVATE, 'Private'),
    ]

    contents = models.CharField(max_length=140)

    access_level = models.IntegerField(choices=ACCESS_LEVEL_CHOICES, default=ACCESS_PUBLIC)

    created_by = models.ForeignKey(User, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

This defines a model class called Post with four fields: contents, access_level, created_at and created_by. The contents field will store the message submitted by the user, while created_at & created_by store post metadata. We will use access_level later to implement post visibility with Oso.

Save this file, and create a migration to make the table to store the Post model in our database:

$ ./manage.py makemigrations social

The makemigrations command will check the models against existing migrations and create new migration scripts as needed. Most types of database changes are auto-detected. Apply this migration as we did before with the migrate command.

Now, we've made our Post model. Let's check that things are working as intended. Instead of making a view, we will use the admin interface to create some posts. Register the Post with the admin interface by adding the following to oso_social/social/admin.py:

# Add Post import (in addition to User)
from .models import User, Post

# ...
admin.site.register(Post)

Now, we can visit the admin site at: http://localhost:8000/admin/. The Post model is available, let's use the admin interface to create a post. Click on Posts, then Add Post in the upper right.

Building%20permissions%20in%20a%20Django%20app%20in%2030%20Minutes%204b3da60512dd49e58b9f8e0cbdfbba6d/Screen_Shot_2020-09-08_at_12.38.05_PM.png

Fill out the form as we did above, and save the new Post. This example illustrates the power of the Django admin interface. With no custom code, we were able to test out our model and interact with it!

Creating the feed view

Ok, enough time in the admin interface. Let's move on with creating the feed view that users will use. Every page in a Django web app has an associated view. When a user visits a particular URL, the view function is responsible for handling the request and producing a response. Let's write a simple view to display Posts.

In oso_social/social/views.py, write the following code:

from django.shortcuts import render
from django.http import HttpResponse

from .models import Post

def list_posts(request):
    # Limit to 10 latest posts
    posts = Post.objects.all().order_by('-created_at')[:10]

    posts_text = ""

    for post in posts:
        posts_text += f"@{post.created_by} {post.contents}"

    return HttpResponse(posts_text)

list_posts is our view function. It first uses the Django QuerySet API and our Post model to get the first 10 posts from the database, ordered by latest post first. Then, we produce a string to respond with using Python format strings. Finally, we return an HttpResponse containing the posts_text.

We wrote our view function, but we aren't done yet! We still have to tell Django when to call it. Django uses URLconf modules for this purpose. Open oso_social/oso_social/urls.py in your editor. This is the root URLconf. It specifies what view functions are run when a request is made for a particular URL. Right now, you will see the url pattern: path('admin/', admin.site.urls) which drives the admin site. Let's add one more pattern:

# Add include import
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('social.urls'))
]

This tells Django to use the oso_social/social/urls.py file to resolve any urls that do not match admin/.

Let's create it. In oso_social/social/urls.py:

from django.urls import path

from .views import list_posts

urlpatterns = [
    path('', list_posts)
]

The only route ('') matches the index page. Django strips the leading slash from a URL, so the root page matches the empty string. Our list_posts function will be called to process requests to /.

Let's try running it! If you still have ./manage.py runserver running, it should have autoreloaded. If not, start it now and visit http://localhost:8000/. You should see a page like the below:

Building%20permissions%20in%20a%20Django%20app%20in%2030%20Minutes%204b3da60512dd49e58b9f8e0cbdfbba6d/Screen_Shot_2020-09-08_at_12.50.05_PM.png

Go ahead and use the admin interface to create a few more posts and see how things look on our new feed page.

Using templates

If you made more than one post, you probably noticed that the rendering isn't ideal. All the posts show up on one line. Let's create a proper HTML page for our response instead of just responding with text. Django's built in template system can help us with this. To start, create a new file at the path oso_social/social/templates/social/list.html.

Add the contents:

<html>
    <body>
        <h1>Feed</h1>
        {% for post in posts %}
            <p><em>@{{ post.created_by.username }}</em> {{ post.contents }} <em>{{ post.created_at|date:"SHORT_DATETIME_FORMAT" }}</em></p>
        {% endfor %}
    </body>
</html>

This HTML is templated using the Django template system. This allows us to build HTML pages that will have variables substituted by our view code. The tags {% for post in posts %} causes the post template variable to be assigned to each item from the iterable posts. Then, we interpolate the username and contents using {{ post.created_by.username }} and {{ post.contents }}. The date is passed through a filter to format it properly for the user. This template will produce a <p> tag for each post.

To use the template, alter our list_posts function in oso_social/social/views.py:

def list_posts(request):
    # Limit to 10 latest posts.
    posts = Post.objects.all().order_by('-created_at')[:10]

    return render(request, 'social/list.html', {'posts': posts})

We used the render function, which tells Django to render the template at social/list.html with the template context {'posts': posts}. The context specifies which variables are available to the templating engine. Django will search any directory called templates in each app for the template name.

Now, our view looks a little better!

Building%20permissions%20in%20a%20Django%20app%20in%2030%20Minutes%204b3da60512dd49e58b9f8e0cbdfbba6d/Screen_Shot_2020-09-08_at_12.58.01_PM.png

Want us to remind you?
We'll email you before the event with a friendly reminder.

Write your first policy