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
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.
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
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
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)
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.
- repo/models – models and managers
- repo/utils –
- repo/tests – tests of functionality
- phugoid/django-simple-multitenant – from 2012 and describes basically the same functionality as the example project I’ve included.