django-scopes
=============
![Build status](https://github.com/raphaelm/django-scopes/actions/workflows/tests.yml/badge.svg)
![PyPI](https://img.shields.io/pypi/v/django-scopes.svg)
[![Python versions](https://img.shields.io/pypi/pyversions/django-scopes.svg)](https://pypi.org/project/django-scopes/)
![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-scopes)
Motivation
----------
Many of us use Django to build multi-tenant applications where every user only ever
gets access to a small, separated fraction of the data in our application, while
at the same time having *some* global functionality that makes separate databases per
client infeasible. While Django does a great job protecting us from building SQL
injection vulnerabilities and similar errors, Django can't protect us from logic
errors and one of the most dangerous types of security issues for multi-tenant
applications is that we leak data across tenants.
It's so easy to forget that one ``.filter`` call and it's hard to catch these errors
in both manual and automated testing, since you usually do not have a lot of clients
in your development setup. Leaving [radical, database-dependent ideas](https://github.com/bernardopires/django-tenant-schemas)
aside, there aren't many approaches available in the ecosystem to prevent these mistakes
from happening aside from rigorous code review.
We'd like to propose this module as a flexible line of defense. It is meant to have
little impact on your day-to-day work, but act as a safeguard in case you build a
faulty query.
Installation
------------
There's nothing required apart from a simple
pip install django-scopes
Compatibility
-------------
This library is tested against **Python 3.8-3.10** and **Django 3.2-4.0**.
Usage
-----
Let's assume we have a multi-tenant blog application consisting of the three models ``Site``,
``Post``, and ``Comment``:
```python
from django.db import models
class Site(models.Model):
name = models.CharField(…)
class Post(models.Model):
site = models.ForeignKey(Site, …)
title = models.CharField(…)
class Comment(models.Model):
post = models.ForeignKey(Post, …)
text = models.CharField(…)
```
In this case, our model `Site` acts as the tenant for the blog posts and their comments, hence
our application will probably be full of statements like
``Post.objects.filter(site=current_site)``, ``Comment.objects.filter(post__site=current_site)``,
or more complex when more flexible permission handling is involved. With **django-scopes**, we
encourage you to still write these queries with your custom permission-based filters, but
we add a custom model manager that has knowledge about posts and comments being part of a
tenant scope:
```python
from django_scopes import ScopedManager
class Post(models.Model):
site = models.ForeignKey(Site, …)
title = models.CharField(…)
objects = ScopedManager(site='site')
class Comment(models.Model):
post = models.ForeignKey(Post, …)
text = models.CharField(…)
objects = ScopedManager(site='post__site')
```
The keyword argument ``site`` defines the name of our **scope dimension**, while the string
``'site'`` or ``'post__site'`` tells us how we can look up the value for this scope dimension
in ORM queries.
You could have multi-dimensional scopes by passing multiple keyword arguments to
``ScopedManager``, e.g. ``ScopedManager(site='post__site', user='author')`` if that is
relevant to your usecase.
Now, with this custom manager, all queries are banned at first:
>>> Comment.objects.all()
ScopeError: A scope on dimension "site" needs to be active for this query.
The only thing that will work is ``Comment.objects.none()``, which is useful e.g. for Django
generic view definitions.
### Activate scopes in contexts
You can now use our context manager to specifically allow queries to a specific blogging site,
e.g.:
```python
from django_scopes import scope
with scope(site=current_site):
Comment.objects.all()
```
This will *automatically* add a ``.filter(post__site=current_site)`` to all of your queries.
Again, we recommend that you *still* write them explicitly, but it is nice to know to have a
safeguard.
Of course, you can still explicitly enter a non-scoped context to access all the objects in your
system:
```python
with scope(site=None):
Comment.objects.all()
```
This also works correctly nested within a previously defined scope. You can also activate multiple
values at once:
```python
with scope(site=[site1, site2]):
Comment.objects.all()
```
Sounds cumbersome to put those ``with`` statements everywhere? Maybe not at all: You probably
already have a middleware that determines the site (or tenant, in general) for every request
based on URL or logged in user, and you can easily use it there to just automatically wrap
it around all your tenant-specific views.
Functions can opt out of this behavior by using
```python
from django_scopes import scopes_disabled
with scopes_disabled():
…
# OR
@scopes_disabled()
def fun(…):
…
```
Please note that **django-scopes** is also active during migrations, so if you are writing a
data migration – or have written one in the past! – you'll have to add appropriate scoping
or use the ``scopes_disabled`` context.
### Custom manager classes
If you were already using a custom manager class, you can pass it to a `ScopedManager` with the `_manager_class`
keyword like this:
from django.db import models
```python
from django.db import models
class SiteManager(models.Manager):
def get_queryset(self):
return super().get_queryset().exclude(name__startswith='test')
class Site(models.Model):
name = models.CharField(…)
objects = ScopedManager(site='site', _manager_class=SiteManager)
```
### Scoping the User model
Assume you've got two models `User` and `Post`. Using the examples above, you can ensure that users only ever see their own diary posts. But how about leaking other users to the currently logged in user? If you application doesn't have much (or any) interaction between users, you can scope the user model. Please note that you'll need a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model). Which base classes your user and manager work off will very between projects.
```python
class User(AbstractUser):
objects = ScopedManager(user='pk', _manager_class=UserManager)
# (...)
```
Activating the scope comes with a little caveat - you need to use the users primary key, not the whole object:
```python
with scope(user=request.user.pk):
# do something :)
```
Caveats
-------
### Locking
With django-scopes, a seemingly innocent query like
```python
Comment.objects.select_for_update().get(pk=3)
```
could cause unexpected locking across your database, since django-scopes will auto-add one or more ``JOIN`` statements to the query, and joined tables will **also be locked**.
One possible fix is of course using ``scopes_disabled()``, around this query.
On most modern databases, there's also a way to specify explicitly which tables you want locked:
```python
Comment.objects.select_for_update(of=("self",)).get(pk=3)
```
You can check if your database supports this feature at runtime using ``connection.features.has_select_for_update_of``.
### Admin
**django-scopes** is not compatible with the django admin out of the box, integration requires a
custom middleware. (If you write one, please open a PR to include it in this package!)
### Testing
We want to enforce scoping by default to stay safe, which unfortunately
breaks the Django test runner as well as pytest-django. For now, we haven't found
a better solution than to monkeypatch it:
```python
from django.test import utils
from django_scopes import scopes_disabled
utils.setup_databases = scopes_disabled()(utils.setup_databases)
```
You can wrap many of your test and fixtures inside ``scopes_disabled()`` as well, but we wouldn't advise to do it with all of them: Especially when writing higher-level functional tests, such as tests using Django's test client or tests testing celery tasks, you should make sure that your application code runs as it does in production. Therefore, writing tests for a project using django-scopes often looks like this:
```python
@pytest.mark.django_db
def test_a_view(client):
with scopes_disabled():
u = User.objects.create(...)
client.post('/user/{}/delete'.format(u.pk))
with scopes_disabled():
assert not User.objects.filter(pk=u.pk).exists()
```
If you want to disable scoping or activate a certain scope whenever a specific fixture is used, you can do so in py.test like this:
```python
@pytest.fixture
def site():
s = Site.objects.create(...)
with scope(site=s):
yield s
```
When trying to port a project with *lots* of fixtures, it can be helpful to roll a small py.test plugin in your ``conftest.py`` to just globally disable scoping for all fixtures which are not yielding fixtures (like the one above):
```python
@pytest.hookimpl(hookwrapper=True)
def pytest_fixture_setup(fixturedef, request):
if inspect.isgeneratorfunction(fixturedef.func):
yield
else:
with scopes_disabled():
yield
```
### ModelForms
When using model forms, Django will automatically generate choice fields on foreign
keys and many-to-many fields. This won't work here, so we supply helper field
classes ``SafeModelChoiceField`` and ``SafeModelMultipleChoiceField`` that use an
empty queryset instead:
```python
from django.forms import ModelForm
from django_scopes.forms import SafeModelChoiceField
class PostMethodForm(ModelForm):
class Meta:
model = Comment
field_classes = {
'post': SafeModelChoiceField,
}
```
### django-filter
We noticed that ``django-filter`` also runs some queries when generating filtersets.
Currently, our best workaround is this:
```python
from django_scopes import scopes_disabled
with scopes_disabled():
class CommentFilter(FilterSet):
…
```
### Uniqueness
One subtle class of bug that can be introduced by adding django-scopes to your project is if you try to generate unique identifiers in your database with a pattern like this:
```python
def generate_unique_value():
while True:
key = _generate_random_key()
if not Model.objects.filter(key=key).exists():
return key
```
If you want keys to be unique across tenants, make sure to wrap such functions with ``scopes_disabled()``!
When using a [ModelForm](https://docs.djangoproject.com/en/dev/topics/forms/modelforms/) (or [class based view](https://docs.djangoproject.com/en/dev/topics/class-based-views/)) to create or update a model, unexpected IntegrityErrors may occur. ModelForms perform a uniqueness check before actually saving the model. If that check runs in a scoped context, it cannot find conflicting instances, leading to an IntegrityErrors once the actual `.save()` happens. To combat this, wrap the call in ``scopes_disabled()``.
```python
class Site(models.Model):
name = models.CharField(unique=True, …)
# (...)
def validate_unique(self, *args, **kwargs):
with scopes_disabled():
super().validate_unique(*args, **kwargs)
```
## Further reading
If you'd like to read more about the practical use of django-scopes, there is a [blog
post](https://behind.pretix.eu/2019/06/17/scopes/) about its introduction in the [pretix](https://pretix.eu) project.
[Here](https://rixx.de/blog/using-the-django-shell-with-django-scopes/) is a guide on how to write a ``shell_scoped``
django-admin command to provide a scoped Django shell.
Raw data
{
"_id": null,
"home_page": "https://github.com/raphaelm/django-scopes",
"name": "django-scopes",
"maintainer": "",
"docs_url": null,
"requires_python": "",
"maintainer_email": "",
"keywords": "json database models",
"author": "Raphael Michel",
"author_email": "mail@raphaelmichel.de",
"download_url": "https://files.pythonhosted.org/packages/a5/d7/a26ccb685b64e8e0b21f107b01ea16636a899a380175fe29d7c01d3d8395/django-scopes-2.0.0.tar.gz",
"platform": null,
"description": "django-scopes\n=============\n\n![Build status](https://github.com/raphaelm/django-scopes/actions/workflows/tests.yml/badge.svg)\n![PyPI](https://img.shields.io/pypi/v/django-scopes.svg)\n[![Python versions](https://img.shields.io/pypi/pyversions/django-scopes.svg)](https://pypi.org/project/django-scopes/)\n![PyPI - Django Version](https://img.shields.io/pypi/djversions/django-scopes)\n\nMotivation\n----------\n\nMany of us use Django to build multi-tenant applications where every user only ever\ngets access to a small, separated fraction of the data in our application, while\nat the same time having *some* global functionality that makes separate databases per\nclient infeasible. While Django does a great job protecting us from building SQL\ninjection vulnerabilities and similar errors, Django can't protect us from logic\nerrors and one of the most dangerous types of security issues for multi-tenant\napplications is that we leak data across tenants.\n\nIt's so easy to forget that one ``.filter`` call and it's hard to catch these errors\nin both manual and automated testing, since you usually do not have a lot of clients\nin your development setup. Leaving [radical, database-dependent ideas](https://github.com/bernardopires/django-tenant-schemas)\naside, there aren't many approaches available in the ecosystem to prevent these mistakes\nfrom happening aside from rigorous code review.\n\nWe'd like to propose this module as a flexible line of defense. It is meant to have\nlittle impact on your day-to-day work, but act as a safeguard in case you build a\nfaulty query.\n\nInstallation\n------------\n\nThere's nothing required apart from a simple\n\n\tpip install django-scopes\n\nCompatibility\n-------------\n\nThis library is tested against **Python 3.8-3.10** and **Django 3.2-4.0**.\n\nUsage\n-----\n\nLet's assume we have a multi-tenant blog application consisting of the three models ``Site``,\n``Post``, and ``Comment``:\n\n```python\nfrom django.db import models\n\nclass Site(models.Model):\n\tname = models.CharField(\u2026)\n\nclass Post(models.Model):\n\tsite = models.ForeignKey(Site, \u2026)\n\ttitle = models.CharField(\u2026)\n\nclass Comment(models.Model):\n\tpost = models.ForeignKey(Post, \u2026)\n\ttext = models.CharField(\u2026)\n```\n\nIn this case, our model `Site` acts as the tenant for the blog posts and their comments, hence\nour application will probably be full of statements like\n``Post.objects.filter(site=current_site)``, ``Comment.objects.filter(post__site=current_site)``,\nor more complex when more flexible permission handling is involved. With **django-scopes**, we\nencourage you to still write these queries with your custom permission-based filters, but\nwe add a custom model manager that has knowledge about posts and comments being part of a\ntenant scope:\n\n```python\nfrom django_scopes import ScopedManager\n\nclass Post(models.Model):\n\tsite = models.ForeignKey(Site, \u2026)\n\ttitle = models.CharField(\u2026)\n\n\tobjects = ScopedManager(site='site')\n\nclass Comment(models.Model):\n\tpost = models.ForeignKey(Post, \u2026)\n\ttext = models.CharField(\u2026)\n\n\tobjects = ScopedManager(site='post__site')\n```\n\nThe keyword argument ``site`` defines the name of our **scope dimension**, while the string\n``'site'`` or ``'post__site'`` tells us how we can look up the value for this scope dimension\nin ORM queries.\n\nYou could have multi-dimensional scopes by passing multiple keyword arguments to\n``ScopedManager``, e.g. ``ScopedManager(site='post__site', user='author')`` if that is\nrelevant to your usecase.\n\nNow, with this custom manager, all queries are banned at first:\n\n\t>>> Comment.objects.all()\n\tScopeError: A scope on dimension \"site\" needs to be active for this query.\n\nThe only thing that will work is ``Comment.objects.none()``, which is useful e.g. for Django\ngeneric view definitions.\n\n### Activate scopes in contexts\n\nYou can now use our context manager to specifically allow queries to a specific blogging site,\ne.g.:\n\n```python\nfrom django_scopes import scope\n\nwith scope(site=current_site):\n\tComment.objects.all()\n```\n\nThis will *automatically* add a ``.filter(post__site=current_site)`` to all of your queries.\nAgain, we recommend that you *still* write them explicitly, but it is nice to know to have a\nsafeguard.\n\nOf course, you can still explicitly enter a non-scoped context to access all the objects in your\nsystem:\n\n```python\nwith scope(site=None):\n\tComment.objects.all()\n```\n\nThis also works correctly nested within a previously defined scope. You can also activate multiple\nvalues at once:\n\n```python\nwith scope(site=[site1, site2]):\n\tComment.objects.all()\n```\n\nSounds cumbersome to put those ``with`` statements everywhere? Maybe not at all: You probably\nalready have a middleware that determines the site (or tenant, in general) for every request\nbased on URL or logged in user, and you can easily use it there to just automatically wrap\nit around all your tenant-specific views.\n\nFunctions can opt out of this behavior by using\n\n```python\nfrom django_scopes import scopes_disabled\n\n\nwith scopes_disabled():\n \u2026\n\n# OR\n\n@scopes_disabled()\ndef fun(\u2026):\n \u2026\n```\n\nPlease note that **django-scopes** is also active during migrations, so if you are writing a\ndata migration \u2013 or have written one in the past! \u2013 you'll have to add appropriate scoping\nor use the ``scopes_disabled`` context.\n\n### Custom manager classes\n\nIf you were already using a custom manager class, you can pass it to a `ScopedManager` with the `_manager_class`\nkeyword like this:\nfrom django.db import models\n\n```python\nfrom django.db import models\n\nclass SiteManager(models.Manager):\n\n\tdef get_queryset(self):\n\t\treturn super().get_queryset().exclude(name__startswith='test')\n\nclass Site(models.Model):\n\tname = models.CharField(\u2026)\n\n\tobjects = ScopedManager(site='site', _manager_class=SiteManager)\n```\n\n\n### Scoping the User model\n\nAssume you've got two models `User` and `Post`. Using the examples above, you can ensure that users only ever see their own diary posts. But how about leaking other users to the currently logged in user? If you application doesn't have much (or any) interaction between users, you can scope the user model. Please note that you'll need a [custom user model](https://docs.djangoproject.com/en/dev/topics/auth/customizing/#specifying-a-custom-user-model). Which base classes your user and manager work off will very between projects.\n\n```python\nclass User(AbstractUser):\n\tobjects = ScopedManager(user='pk', _manager_class=UserManager)\n\n\t# (...)\n```\n\nActivating the scope comes with a little caveat - you need to use the users primary key, not the whole object:\n\n```python\nwith scope(user=request.user.pk):\n\t# do something :)\n```\n\nCaveats\n-------\n\n### Locking\n\nWith django-scopes, a seemingly innocent query like\n\n```python\nComment.objects.select_for_update().get(pk=3)\n```\n\ncould cause unexpected locking across your database, since django-scopes will auto-add one or more ``JOIN`` statements to the query, and joined tables will **also be locked**.\nOne possible fix is of course using ``scopes_disabled()``, around this query.\nOn most modern databases, there's also a way to specify explicitly which tables you want locked:\n\n```python\nComment.objects.select_for_update(of=(\"self\",)).get(pk=3)\n```\n\nYou can check if your database supports this feature at runtime using ``connection.features.has_select_for_update_of``.\n\n### Admin\n\n**django-scopes** is not compatible with the django admin out of the box, integration requires a\ncustom middleware. (If you write one, please open a PR to include it in this package!)\n\n### Testing\n\nWe want to enforce scoping by default to stay safe, which unfortunately\nbreaks the Django test runner as well as pytest-django. For now, we haven't found\na better solution than to monkeypatch it:\n\n```python\nfrom django.test import utils\nfrom django_scopes import scopes_disabled\n\nutils.setup_databases = scopes_disabled()(utils.setup_databases)\n```\n\nYou can wrap many of your test and fixtures inside ``scopes_disabled()`` as well, but we wouldn't advise to do it with all of them: Especially when writing higher-level functional tests, such as tests using Django's test client or tests testing celery tasks, you should make sure that your application code runs as it does in production. Therefore, writing tests for a project using django-scopes often looks like this:\n\n```python\n@pytest.mark.django_db\ndef test_a_view(client):\n with scopes_disabled():\n u = User.objects.create(...)\n client.post('/user/{}/delete'.format(u.pk))\n with scopes_disabled():\n \tassert not User.objects.filter(pk=u.pk).exists()\n```\n\nIf you want to disable scoping or activate a certain scope whenever a specific fixture is used, you can do so in py.test like this:\n\n```python\n@pytest.fixture\ndef site():\n s = Site.objects.create(...)\n with scope(site=s):\n yield s\n```\n\nWhen trying to port a project with *lots* of fixtures, it can be helpful to roll a small py.test plugin in your ``conftest.py`` to just globally disable scoping for all fixtures which are not yielding fixtures (like the one above):\n\n```python\n@pytest.hookimpl(hookwrapper=True)\ndef pytest_fixture_setup(fixturedef, request):\n if inspect.isgeneratorfunction(fixturedef.func):\n yield\n else:\n with scopes_disabled():\n yield\n```\n\n### ModelForms\n\nWhen using model forms, Django will automatically generate choice fields on foreign\nkeys and many-to-many fields. This won't work here, so we supply helper field\nclasses ``SafeModelChoiceField`` and ``SafeModelMultipleChoiceField`` that use an\nempty queryset instead:\n\n```python\nfrom django.forms import ModelForm\nfrom django_scopes.forms import SafeModelChoiceField\n\nclass PostMethodForm(ModelForm):\n class Meta:\n model = Comment\n field_classes = {\n 'post': SafeModelChoiceField,\n }\n```\n\n### django-filter\n\nWe noticed that ``django-filter`` also runs some queries when generating filtersets.\nCurrently, our best workaround is this:\n\n```python\nfrom django_scopes import scopes_disabled\n\nwith scopes_disabled():\n class CommentFilter(FilterSet):\n \u2026\n```\n\n### Uniqueness\n\nOne subtle class of bug that can be introduced by adding django-scopes to your project is if you try to generate unique identifiers in your database with a pattern like this:\n\n```python\n\ndef generate_unique_value():\n while True:\n key = _generate_random_key()\n if not Model.objects.filter(key=key).exists():\n return key\n```\n\nIf you want keys to be unique across tenants, make sure to wrap such functions with ``scopes_disabled()``!\n\nWhen using a [ModelForm](https://docs.djangoproject.com/en/dev/topics/forms/modelforms/) (or [class based view](https://docs.djangoproject.com/en/dev/topics/class-based-views/)) to create or update a model, unexpected IntegrityErrors may occur. ModelForms perform a uniqueness check before actually saving the model. If that check runs in a scoped context, it cannot find conflicting instances, leading to an IntegrityErrors once the actual `.save()` happens. To combat this, wrap the call in ``scopes_disabled()``.\n\n```python\nclass Site(models.Model):\n name = models.CharField(unique=True, \u2026)\n\n # (...)\n\n def validate_unique(self, *args, **kwargs):\n with scopes_disabled():\n super().validate_unique(*args, **kwargs)\n```\n\n## Further reading\n\nIf you'd like to read more about the practical use of django-scopes, there is a [blog\npost](https://behind.pretix.eu/2019/06/17/scopes/) about its introduction in the [pretix](https://pretix.eu) project.\n\n[Here](https://rixx.de/blog/using-the-django-shell-with-django-scopes/) is a guide on how to write a ``shell_scoped``\ndjango-admin command to provide a scoped Django shell.\n",
"bugtrack_url": null,
"license": "Apache License 2.0",
"summary": "Scope querys in multi-tenant django applications",
"version": "2.0.0",
"split_keywords": [
"json",
"database",
"models"
],
"urls": [
{
"comment_text": "",
"digests": {
"blake2b_256": "153d94d82839c111a36145b5ec1fb407a85f9a460af5974a07f4c6d3cc414358",
"md5": "91d1d74a4c9c86a9329f69eec2adafaf",
"sha256": "9cf521b4d543ffa2ff6369fb5a1dda03567e862ba89626c01405f3d93ca04724"
},
"downloads": -1,
"filename": "django_scopes-2.0.0-py3-none-any.whl",
"has_sig": false,
"md5_digest": "91d1d74a4c9c86a9329f69eec2adafaf",
"packagetype": "bdist_wheel",
"python_version": "py3",
"requires_python": null,
"size": 16660,
"upload_time": "2023-04-22T17:08:45",
"upload_time_iso_8601": "2023-04-22T17:08:45.058382Z",
"url": "https://files.pythonhosted.org/packages/15/3d/94d82839c111a36145b5ec1fb407a85f9a460af5974a07f4c6d3cc414358/django_scopes-2.0.0-py3-none-any.whl",
"yanked": false,
"yanked_reason": null
},
{
"comment_text": "",
"digests": {
"blake2b_256": "a5d7a26ccb685b64e8e0b21f107b01ea16636a899a380175fe29d7c01d3d8395",
"md5": "0f5369791d4ac7d5e404cb8b0bbcabb6",
"sha256": "d190d9a2462bce812bc6fdd254e47ba031f6fba3279c8ac7397c671df0a4e54f"
},
"downloads": -1,
"filename": "django-scopes-2.0.0.tar.gz",
"has_sig": false,
"md5_digest": "0f5369791d4ac7d5e404cb8b0bbcabb6",
"packagetype": "sdist",
"python_version": "source",
"requires_python": null,
"size": 15118,
"upload_time": "2023-04-22T17:07:52",
"upload_time_iso_8601": "2023-04-22T17:07:52.352864Z",
"url": "https://files.pythonhosted.org/packages/a5/d7/a26ccb685b64e8e0b21f107b01ea16636a899a380175fe29d7c01d3d8395/django-scopes-2.0.0.tar.gz",
"yanked": false,
"yanked_reason": null
}
],
"upload_time": "2023-04-22 17:07:52",
"github": true,
"gitlab": false,
"bitbucket": false,
"github_user": "raphaelm",
"github_project": "django-scopes",
"travis_ci": false,
"coveralls": true,
"github_actions": true,
"lcname": "django-scopes"
}