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.
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.
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:
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!