Automatic Tenant Scoping
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.
Links
I’ve linked an example Django app with some tests for this implementation. There aren’t any views, but the data model is there.
- repo/models – models and managers
- repo/utils –
get_current_user
and TLS - repo/tests – tests of functionality
Prior Art
- phugoid/django-simple-multitenant – from 2012 and describes basically the same functionality as the example project I’ve included.