Building a Django app with data access control in 30 minutes

David Hatch

Nearly every application needs to enable its users to see only their data. Many other applications go further and add more controls, like sharing, or making some content private and public. These concepts are increasingly important to get right as data privacy consistently finds itself at the center of the conversation in technical, business and political communities.

In this post, we will explore how to build a simple social app that allows users to share posts, like Twitter. We will use Django, a popular Python web framework that includes an ORM and templating system. Unlike Twitter, our application will include post visibility control. We will implement this access control using oso, an open source policy engine that is embedded in your application. The django-oso package makes it possible to use oso in a Django app with little configuration.

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.

Starting our project

To get started, we will 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 will 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%20a%20Django%20app%20with%20data%20access%20controls%20in%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%20a%20Django%20app%20with%20data%20access%20controls%20in%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%20a%20Django%20app%20with%20data%20access%20controls%20in%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%20a%20Django%20app%20with%20data%20access%20controls%20in%204b3da60512dd49e58b9f8e0cbdfbba6d/Screen_Shot_2020-09-08_at_12.58.01_PM.png

Adding a new post form

We have one more view to create: the "new post" view. This view will allow the user to submit a post, and will save it in our database. Let's start by creating a template. In oso_social/social/templates/social/new_post.html:

<html>
    <body>
        <h1>New post</h1>
        <form action="{% url 'new_post' %} " method="post">
            {% csrf_token %}
            {{ form }}
            <input type="submit" value="Create post" />
        </form>
    </body>
</html>

This template creates an HTML form. We will use a Django form to replace the {{ form }} variable. The {% csrf_token %} includes a token to prevent CSRF (cross-site request forgery) attacks. The feature is built-in to Django. We must create a new Form class to use with our template. Create the file oso_social/social/forms.py:

from django.forms import ModelForm

from .models import Post

class PostForm(ModelForm):
    class Meta:
        model = Post
        fields = ['contents', 'access_level']

The PostForm class inherits from ModelForm, which produces a form that has inputs based on the model's fields. The access_level dropdown won't have any impact until we implement authorization.

Next, let's hook this all up in our view function:

# New imports

from django.http import HttpResponseNotAllowed, HttpResponseRedirect
from django.urls import reverse

from .forms import PostForm

# ...

def new_post(request):
    if request.method == 'POST':
        # This branch runs when the user submits the form.

        # Create an instance of the form with the submitted data.
        form = PostForm(request.POST)

        # Convert the form into a model instance.  commit=False postpones
        # saving to the database.
        post = form.save(commit=False)

        # Make the currently logged in user the Post creator.
        post.created_by = request.user

        # Save post in database.
        post.save()

        # Rediect to post list.
        return HttpResponseRedirect(reverse('index'))
    elif request.method == 'GET':
        # GET evaluated when form loaded.
        form = PostForm()
        # Render the view with the form for the user to fill out.
        return render(request, 'social/new_post.html', { 'form': form })
    else:
        return HttpResponseNotAllowed(['GET', 'POST'])

Finally, we will update our URLconf to include the new post view:

from django.urls import path

from .views import list_posts, new_post

urlpatterns = [
    path('', list_posts, name='index'),
    path('new/', new_post, name='new_post'),
]

The name argument to path allows us to lookup the url path by name, which we use both in our templates ({% url 'new_post' %}) and views reverse('index').

Let's add a button to the index page to route to the new post page in oso_social/social/templates/social/list.html:

<a href="{% url 'new_post' %}">New post</a>

Adding login and logout pages

Next, we need to add login functionality to our app, so users that do not have access to the admin interface can use it. The built-in Django auth app has views that can manage login and logout.

To take advantage of them, we need to add entries to our URLconf in oso_social/social/urls.py:

from django.contrib.auth import views as auth_views

urlpatterns = [
    # ...
    path('login/', auth_views.LoginView.as_view(template_name='social/login.html'), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
]

We must supply a template for the login view and we will also want to add login & logout buttons on each page. We will take advantage of Django's template inheritance system to create a base template containing these buttons. Create the file oso_social/social/templates/social/base.html:

<!DOCTYPE html>
<html>
    <head>
        <title>{% block title %}oso-social{% endblock %}</title>
    </head>
    <body>
        {% if user.is_authenticated %}
        <p>Welcome <em>@{{ user.username }}.</em></p>
        <a href={% url 'logout' %}>Logout</a>
        {% endif %}
        <a href={% url 'login' %}>Login</a>

        {% block contents %}
        {% endblock %}
    </body>
</html>

This sets up a logout & login button, as well as a contents area {% block contents %} that other templates can fill in.

Update our list.html template:

{% extends 'social/base.html' %}

{% block contents %}

<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 %}

<a href="{% url 'new_post' %}">New post</a>

{% endblock %}

and new_post.html:

{% extends 'social/base.html' %}

{% block contents %}

<h1>New post</h1>
<form action="{% url 'new_post' %} " method="post">
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="Create post" />
</form>

{% endblock %}

The {% extends %} tag indicates that this template is inheriting from the base template, and substituting the contents between {% block contents %} and {% endblock %} into the base.

Now, create our login template! In oso_social/social/templates/social/login.html:

{% extends 'social/base.html' %}

{% block contents %}

<h1>Login</h1>
    <form method="post" action={% url 'login' %}>
    {% csrf_token %}
    {{ form }}
    <input type="submit" value="login">
    <input type="hidden" name="next" value="{{ next }}">
</form>

{% endblock %}

We will add the following entries to our settings file, which are necessary to ensure these built-in views function:

LOGIN_REDIRECT_URL = 'index'
LOGIN_URL = 'login'
LOGOUT_REDIRECT_URL = 'index'

Finally, we can add the @login_required decorator to our new_post view in oso_social/social/views.py which will redirect a user to the login page if they attempt to create a post before logging in:

from django.contrib.auth.decorators import login_required

@login_required
def new_post(request):
    # ...

Using oso to hide private posts

Now that we've got the basics covered, let's give users the capability to create private posts. We will use oso for this.

Recall that our model definition for Post included a field called access_level :

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

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

This integer field stores two choices: ACCESS_PUBLIC or ACCESS_PRIVATE. The choice values are 0 & 1 respectively, but the user facing label is 'Public' or 'Private'. See here for more on choices.

Now, let's add django-oso to our app and setup our Post policy. First, add django_oso to the list of installed applications.

In oso_social/oso_social/settings.py, add django_oso to the list of INSTALLED_APPS:

INSTALLED_APPS = [
    # ...,
    'django_oso',
]

django-oso provides an authorize function that will evaluate the access control policy for a given actor, action and resource. We will use this function to filter Posts from the list view. In oso_social/social/views.py:

# ...
from django.core.exceptions import PermissionDenied

from django_oso.auth import authorize

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

    authorized_posts = []
    for post in posts:
        try:
            authorize(request, post, action="view")
            authorized_posts.append(post)
        except PermissionDenied:
            continue

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

For every post fetched from the database, we call authorize. Posts that cannot be viewed by the user cause an PermissionDenied exception to be raised. These are filtered out of the list of authorized_posts that is returned to the user.

Finally, let's write our policy. Create a file called oso_social/social/policy/post.polar:

# 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;

Every oso policy is made up of rules, which start with a rule head containing a predicate (allow(actor, action, resource) in this case) and a rule body. The authorize function will query the policy for allow rules. If the actor, action and resource being authorized matches a rule head (the portion of the rule before if) the body will be evaluated to determine whether the query is successful.

In this policy, we have two rules. The first rule states that any actor can perform the "view" action on a post that has the public access level. In the head of the rule, we use _actor to identify the actor, which indicates that the variable will be unused and match any value. The action must match the literal value "view" and the post must be an instance of the Django model social.Post (The : TYPE_NAME syntax indicates a type restriction, and the django-oso plugin makes Django models accessible in the policy under their <app_name>::<ModelName>).

The second rule has the same head, but we require the actor to be a social.User model. This rule will only match for logged in users (non-logged in users have type django::contrib::auth::AnonymousUser). Then, we check that the access level is private and the post is created by the actor trying to access it. This encodes the rule "actors can access their own private posts".

Now, let's test our app. Create a few private posts, and make sure you can see them. Then, create a new user through the admin interface (http://localhost:8000/admin/social/user/). Logout & back in with that user, and confirm that the private posts aren't visible!

Going further with oso

The above policy is very simple, but illustrates the ability of oso policies to interact directly with objects from your application. The policy language, called Polar, is a declarative programming language designed for expressively describing authorization rules.

Since we have standardized our interface into authorization logic through the authorize function, we can easily make modifications to control what posts users can view. Let's suppose we have a new type of user, a moderator. They can view all posts, but other users can only view posts approved by a moderator. The below policy would implement this functionality:

# Moderators can view any Post.
allow(user: social::User, "view", _: social::Post) if
    user.is_moderator;

# Moderators can perform moderation on any Post.
allow(user: social::User, "moderate", _: social::Post) if
    user.is_moderator;

# Allow anyone to view any public posts.
allow(_actor, "view", post: social::Post) if
    # NEW - Public posts must be approved by moderators.
    post.approved and
    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 policy change, along with a few small changes to our models, is all we would need to adjust our app to support moderators.

Wrapping up

In this tutorial, we saw how to create a Django app and use models, views, and templates to produce a simple Twitter clone. The application allows users to create new posts, and view their own private posts & all other public posts.

We then used oso to implement visibility control for Posts, and showed how oso policies allow for extension as the application grows in features & authorization rules.

To read more about oso policies, check out our quickstart guide. Visit our docs for more details on the django-oso library we used in this post, or if you have any other technical questions or feedback, join us on Slack or open an issue.

Get updates from oso.

We won't spam you. Ever.