Generating Django QuerySet filters from oso policies

David Hatch

At oso, our goal is to enable users to cleanly separate authorization logic from the rest of their application code. This separation is particularly challenging to achieve for list endpoints that return multiple records. When we started thinking about list views, we realized a single yes or no authorization result was not sufficient because it only enables filtering a collection of records that are already in the application. Below, we will discuss a solution that allows authorization rules to output filters that can be evaluated more efficiently at the data retrieval layer.

List authorization now

First, let's see how this looks with oso now. For this post, we'll return to the social feed app from our previous post, implemented in Django. The app allows users to login, submit new posts, and view a feed of posts. A post can either be public or private. Below is a code sample from the social app (clonable on GitHub):

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

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

This is the Django view function for the post feed. It renders a list of posts:

Draft%20926a9044929f4c28b5bfb0ed665de88a/Screen_Shot_2020-09-10_at_12.25.55_PM.png

We can see some issues in this code sample. First, we are fetching all posts with no filters from the database. Then, in the for loop we authorize each Post, only adding those that are successfully authorized to the authorized_posts list. If many posts are unauthorized (the common case since users can only see their own private posts), we will have wasted (potentially significant) effort instead of using the database to efficiently fetch only relevant private posts for the current user.

Authorize collections

With django-oso 0.3.0, this code can be rewritten as follows:

def list_posts(request):
    posts = (
        Post.objects.authorize(request, action="view")
        .order_by("-created_at")
    )

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

Under the hood, oso is outputting authorization constraints that can be understood by the Django ORM and translated directly into SQL queries on the database.

Let's take a look at what this looks like. Here's our oso 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;

allow(actor: social::User, "view", _: social::Post) if
    actor.is_moderator();

This policy allows anyone to view public posts, but only view their own private posts. Below is the SQL query generated by the Django ORM (edited for brevity):

SELECT *
FROM   "social_post" 
WHERE  (("social_post"."access_level" = 1 
             AND "social_post"."created_by_id" = 1) 
        OR "social_post"."access_level" = 0);

Here, we can see authorization constraints have been added to the where clause as ORs:

  • social_post.access_level = 1 AND social_post.created_by_id = 1: private posts created by the current actor (The logged in user has ID 1).
  • social_post.access_level = 0: public posts

Since the full power of Polar rules is being used to generate these constraints, we can see the query change when properties of the actor change. For example, if we view the post page as a unauthenticated user, the below query is generated:

SELECT *
FROM   "social_post" 
WHERE  "social_post"."access_level" = 0;

In this case, only the first rule of our policy matched. This is because the actor has type django::contrib::auth::AnonymousUser, not social::User so the type specifier on the second and third rules does not match. We can see that the only constraint added to the query is that the post must be public.

Finally, if we log in as a user that is a moderator (which would match the last rule), the ORM will execute the below SQL query:

SELECT *
FROM       "social_post";

Here, there is no WHERE clause because moderators can view every Post (due to _: Post as the resource parameter in the allow rule).

No changes to our policy or app were required to take advantage of this feature. The authorization policy is still fully abstracted from the rest of our application, but we have efficient enforcement of that policy. If we want to update the policy to change authorization filters, no changes in Python are necessary. The only policy interaction is the interface:

Post.objects.authorize(request, action="view")

Let us know what you think

This feature is available in preview for the django-oso library in 0.3.0, with some limitations:

  • Not all policies can be executed through the Model.objects.authorize interface.
  • Some policies may be translated to invalid constraints that cannot be evaluated by Django, so we do not recommend using this in production yet.

We are working to stabilize this functionality and make it available in more web frameworks beyond Django. We'd love to hear your feedback or questions, join us on Slack or open an issue.

The full example app using this functionality is available here on GitHub.

Get updates from oso.

We won't spam you. Ever.