Update 2018: In less than 24 hours after posting this I’ve learned of DRFs built-in filtering, which provides much of the functionality described in this post. However, that is only applicable for generic DRF views. In this post I present an implementation at the model manager layer that should reduce boilerplate and provide better security (by ensuring you don’t forget to filter querysets) for all areas in your application.


I recently watched Armin Ronacher’s talk, “A Practical Road to SaaS’ in Python” (slides). The main takeaways for me are that thread locals, like the g and request objects of Flask, are helpful in reducing boilerplate and improving application security. Let’s see how we can implement automatic tenant scoping using TLS and the Django ORM.

SQLAlchemy solution

The follow is some SQLAlchemy-specific code presented in Armin’s talk. We want to replicate this functionality in the Django ORM.

def get_current_tenant():
    """Returns current tenant from thread local"""
    raise NotImplementedError()

class TentantQuery(db.Query):
    current_tenant_constrained = True
    def tenant_unconstrained_unsafe(self):
        rv = self._clone()
        rv.current_tenant_constrained = False
        return rv

@db.event.listens_for(TenantQuery, 'before_compile', retval=True)
def ensure_tenant_constrained(query):
    for desc in query.column_descriptions:
        if hasattr(desc['type'], 'tenant') and \
           query.current_tenant_constrained:
             query = query.filter_by(tentant=get_current_tenant())
    return query

The get_current_tenant function, which I’ve truncated because the implementation isn’t relevant for us, gets the current user from thread local storage. The TenantQuery is similar to the Django model.Query. When fully implemented, we can use a query like the following:

test.Project.query.all()
# >> only projects for the current tenant are returned.

test.Project.query.tenant_unconstrained_unsafe().all()
# >> all projects are returned

A Django ORM equivalent

The implementation above uses the SQLAlchemy-specific ORM events API, for which there isn’t a Django equivalent. I’ve spent some time matching Armin’s SQLAlchemy-based API in Django, but the hacking required put me off. For reference, I needed to update the _iterable_class of the QuerySet as well as a few other methods to finish off the API, like count().

Let’s define an API that we can easily implement in Django.

Project.objects.all()
# >> only projects for the current tenant are returned
Project.tenant_unconstrained_unsafe.all()
# >> all projects are returned

Implementing our Django-based solution

Ignoring the use of TLS, the above solution can be implemented using Django ORM model Managers.

from django.db import models

class Tenant(models.Model):
    """Simple model to restrict projects"""


class MissingTenantException(RuntimeError):
    """Tenant is required to access `.objects` of tenant restricted model"""


class TenantManager(models.Manager):
    """Manager that automatically filters Projects to the current tenant"""

    def get_queryset(self):
        """Filter to current tenant by default"""
        from .utils import get_current_tenant

        rv = get_current_tenant()
        if rv is None:
            raise MissingTenantException()
        return super().get_queryset().filter(tenant=rv)


class TenantModelMixin(models.Model):
    """
    Provide tenant-based automatic scoping of models

    example:
        Project.objects.all()
        # returns only projects for the current tenant
        Project.tenant_unconstrained_unsafe().all()
        # returns all projects
    """

    class Meta:
        abstract = True

    tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE)
    # unconstrained manager must come first for it to be default
    tenant_unconstrained_unsafe = models.Manager()
    objects = TenantManager()


class Project(TenantModelMixin, models.Model):
    """Model associated with individual tenants"""

    name = models.CharField(max_length=255)
    description = models.CharField(max_length=255)

Results

We can’t remove our tenant constraint from a query like can with the SQLAlchemy example, but we’re using normal Django APIs, so I think this is a practical compromise.

Project.objects.all()
# returns filtered objects

Project.tenant_unconstrained_unsafe.all()
# returns unfiltered results

For generic views, automatic tenant scoping can reduce boilerplate of manually filtering down queries and ensure you don’t leak data accidentally. More TLS-based functions can be used to provide helpful context, like the current user, anywhere in your application. If you want to allow users to roam across orgs, the get_current_tenant function can be updated to provide the tenant that the current user is roaming on, or potentially provide a list of tenants to filter by.

I’ve linked an example Django app with some tests for this implementation. There aren’t any views, but the data model is there.

Prior Art