Base tenant and domain models for django_omnitenant.
This module defines lightweight, reusable abstract model bases intended
for use in multi-tenant Django projects. It focuses on two primary
concepts:
-
BaseTenant: An abstract representation of a tenant (a tenant may
be isolated by schema or by database). The model contains identifying
fields and a config JSON field used to store backend-specific
connection/configuration information. When a tenant's isolation or
config changes the model coordinates updating the project's
settings.DATABASES and settings.CACHES and resets active
connections so runtime code can pick up the new configuration.
-
BaseDomain: A simple mapping between a tenant and a DNS-style
domain name, intended for projects that resolve tenants by domain or
host header.
Utilities
The module also provides TenantQuerySetManager which is a manager
used by tenant-scoped models to prevent accidental access to models
that are not available to the current tenant. It uses
get_current_tenant (from :mod:.utils) and inspects model-level
attributes such as master_managed and tenant_managed to
determine whether the current tenant should be allowed to access the
model.
Notes on integration
- Both model classes are
abstract; concrete projects should subclass
them and include any project-specific fields.
- The save/delete hooks in
BaseTenant import and interact with the
backend modules at runtime. This keeps the core model free of direct
backend imports until they are required, reducing startup cost and
avoiding circular import issues.
TenantQuerySetManager
Bases: Manager
Manager enforcing tenant-aware access controls for querysets.
Use this manager for models that should be protected from access by
non-authorized tenants (for example models that are shared globally
or reserved for a single master tenant). The manager consults the
current tenant (via :func:get_current_tenant) and the model's
attributes master_managed and tenant_managed to decide
whether access should be permitted.
Behavior
- If no current tenant is present (for example during certain
background tasks) no access check is performed.
- By default a model is considered tenant-managed (
tenant_managed=True).
If a model sets tenant_managed=False it is considered shared
and access is restricted to the public/master tenant unless
master_managed=True is explicitly set on the model or the app.
Source code in django_omnitenant/models.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100 | class TenantQuerySetManager(models.Manager):
"""Manager enforcing tenant-aware access controls for querysets.
Use this manager for models that should be protected from access by
non-authorized tenants (for example models that are shared globally
or reserved for a single master tenant). The manager consults the
current tenant (via :func:`get_current_tenant`) and the model's
attributes ``master_managed`` and ``tenant_managed`` to decide
whether access should be permitted.
Behavior
--------
- If no current tenant is present (for example during certain
background tasks) no access check is performed.
- By default a model is considered tenant-managed (``tenant_managed=True``).
If a model sets ``tenant_managed=False`` it is considered shared
and access is restricted to the public/master tenant unless
``master_managed=True`` is explicitly set on the model or the app.
"""
def _check_tenant_access(self) -> None:
"""Internal helper that raises ``PermissionError`` when the
currently active tenant must not access this model.
The check is intentionally conservative: when a model is
explicitly configured as not tenant-managed we only allow access
when the active tenant is the public/master tenant.
"""
tenant = get_current_tenant()
if not tenant:
return
if tenant.tenant_id == settings.TEST_TENANT_NAME:
return
# By default, models are tenant-managed unless explicitly marked
if not getattr(self.model, "master_managed", False) and not getattr(
self.model, "tenant_managed", True
):
if tenant.tenant_id != settings.PUBLIC_TENANT_NAME:
raise PermissionError(
f"Model '{self.model.__name__}' is not accessible from '{tenant.name}'"
)
def get_queryset(self):
"""Return the base queryset after performing tenant access checks.
Concrete models should use this manager instead of the default
manager if they need automatic tenant access enforcement for all
queryset operations.
"""
self._check_tenant_access()
return super().get_queryset()
|
get_queryset()
Return the base queryset after performing tenant access checks.
Concrete models should use this manager instead of the default
manager if they need automatic tenant access enforcement for all
queryset operations.
Source code in django_omnitenant/models.py
91
92
93
94
95
96
97
98
99
100 | def get_queryset(self):
"""Return the base queryset after performing tenant access checks.
Concrete models should use this manager instead of the default
manager if they need automatic tenant access enforcement for all
queryset operations.
"""
self._check_tenant_access()
return super().get_queryset()
|
BaseTenant
Bases: Model
Abstract tenant model providing identity and lifecycle hooks.
Subclass this model to add tenant records to a concrete project. The
class captures three important pieces of information:
name: human readable tenant name
tenant_id: a slug/identifier used for routing and aliasing. And also for the schema name if isolation type is schema
isolation_type: whether the tenant is isolated by schema or by
a separate database
The config JSONField is intended to store backend-specific
settings such as connection strings or per-tenant options. When
fields that affect the runtime connection configuration (for
example config or isolation_type) change, the model's
save hook updates settings.DATABASES/settings.CACHES and
resets the relevant Django connections so the application can begin
using the new configuration without a full restart.
Source code in django_omnitenant/models.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218 | class BaseTenant(models.Model):
"""Abstract tenant model providing identity and lifecycle hooks.
Subclass this model to add tenant records to a concrete project. The
class captures three important pieces of information:
- ``name``: human readable tenant name
- ``tenant_id``: a slug/identifier used for routing and aliasing. And also for the schema name if `isolation` type is `schema`
- ``isolation_type``: whether the tenant is isolated by schema or by
a separate database
The ``config`` JSONField is intended to store backend-specific
settings such as connection strings or per-tenant options. When
fields that affect the runtime connection configuration (for
example ``config`` or ``isolation_type``) change, the model's
``save`` hook updates ``settings.DATABASES``/``settings.CACHES`` and
resets the relevant Django connections so the application can begin
using the new configuration without a full restart.
"""
class IsolationType(models.IntegerChoices):
"""Enumeration for tenant isolation strategies.
- ``SCHEMA``: tenant lives in a separate database schema
- ``DATABASE``: tenant lives in its own database (different alias)
"""
SCHEMA = 0, "Schema"
DATABASE = 1, "Database"
# HYBRID = "HYB", "Hybrid"
name = models.CharField(max_length=100)
tenant_id = models.SlugField(
unique=True,
validators=[validate_dns_label],
help_text="Must be a valid DNS label (RFC 1034/1035).",
)
isolation_type = models.PositiveSmallIntegerField(choices=IsolationType.choices)
config = models.JSONField(
default=dict,
blank=True,
help_text="Backend-specific configuration or metadata, such as connection strings.",
)
created_at = models.DateTimeField(auto_now_add=True)
# Use the tenant-aware manager so queries are subject to tenant access checks
objects: TenantQuerySetManager = TenantQuerySetManager()
class Meta:
abstract = True
def __str__(self):
"""Return a compact, human-readable representation of the tenant."""
return f"{self.name}({self.tenant_id})"
def save(self, *args, **kwargs):
"""Persist the tenant and apply any runtime configuration updates.
The method performs the following steps:
1. Detects which fields changed compared to the stored instance
(when updating an existing record).
2. Saves the model using the standard Django flow.
3. If ``config`` or ``isolation_type`` were changed, update
``settings.DATABASES`` and/or ``settings.CACHES`` and reset
DB/cache connections so the running process can pick up the
new backend configuration.
Note: backend imports are performed lazily inside the method to
avoid circular imports and to keep module import time small.
"""
if self.pk:
old = type(self).objects.get(pk=self.pk)
changed_fields = [
f.name
for f in self._meta.fields
if getattr(old, f.name) != getattr(self, f.name)
]
else:
changed_fields = []
super().save(*args, **kwargs)
if any(field in changed_fields for field in ["config", "isolation_type"]):
from django_omnitenant.backends.cache_backend import CacheTenantBackend
from .utils import reset_cache_connection, reset_db_connection
if self.isolation_type == self.IsolationType.DATABASE:
from django_omnitenant.backends.database_backend import (
DatabaseTenantBackend,
)
alias, config = DatabaseTenantBackend.get_alias_and_config(self)
settings.DATABASES[alias] = config
reset_db_connection(alias)
alias, config = CacheTenantBackend.get_alias_and_config(self)
settings.CACHES[alias] = config
reset_cache_connection(alias)
def delete(self, *args, **kwargs):
"""Delete the tenant and instruct the configured backend to remove resources.
After the database record is removed the tenant backend is asked to
perform any required cleanup (for example dropping a schema or
removing an external database). The method returns the result of
``super().delete()``.
"""
result = super().delete(*args, **kwargs)
backend = get_tenant_backend(self)
backend.delete()
return result
|
IsolationType
Bases: IntegerChoices
Enumeration for tenant isolation strategies.
SCHEMA: tenant lives in a separate database schema
DATABASE: tenant lives in its own database (different alias)
Source code in django_omnitenant/models.py
123
124
125
126
127
128
129
130
131 | class IsolationType(models.IntegerChoices):
"""Enumeration for tenant isolation strategies.
- ``SCHEMA``: tenant lives in a separate database schema
- ``DATABASE``: tenant lives in its own database (different alias)
"""
SCHEMA = 0, "Schema"
DATABASE = 1, "Database"
|
__str__()
Return a compact, human-readable representation of the tenant.
Source code in django_omnitenant/models.py
| def __str__(self):
"""Return a compact, human-readable representation of the tenant."""
return f"{self.name}({self.tenant_id})"
|
save(*args, **kwargs)
Persist the tenant and apply any runtime configuration updates.
The method performs the following steps:
- Detects which fields changed compared to the stored instance
(when updating an existing record).
- Saves the model using the standard Django flow.
- If
config or isolation_type were changed, update
settings.DATABASES and/or settings.CACHES and reset
DB/cache connections so the running process can pick up the
new backend configuration.
Note: backend imports are performed lazily inside the method to
avoid circular imports and to keep module import time small.
Source code in django_omnitenant/models.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204 | def save(self, *args, **kwargs):
"""Persist the tenant and apply any runtime configuration updates.
The method performs the following steps:
1. Detects which fields changed compared to the stored instance
(when updating an existing record).
2. Saves the model using the standard Django flow.
3. If ``config`` or ``isolation_type`` were changed, update
``settings.DATABASES`` and/or ``settings.CACHES`` and reset
DB/cache connections so the running process can pick up the
new backend configuration.
Note: backend imports are performed lazily inside the method to
avoid circular imports and to keep module import time small.
"""
if self.pk:
old = type(self).objects.get(pk=self.pk)
changed_fields = [
f.name
for f in self._meta.fields
if getattr(old, f.name) != getattr(self, f.name)
]
else:
changed_fields = []
super().save(*args, **kwargs)
if any(field in changed_fields for field in ["config", "isolation_type"]):
from django_omnitenant.backends.cache_backend import CacheTenantBackend
from .utils import reset_cache_connection, reset_db_connection
if self.isolation_type == self.IsolationType.DATABASE:
from django_omnitenant.backends.database_backend import (
DatabaseTenantBackend,
)
alias, config = DatabaseTenantBackend.get_alias_and_config(self)
settings.DATABASES[alias] = config
reset_db_connection(alias)
alias, config = CacheTenantBackend.get_alias_and_config(self)
settings.CACHES[alias] = config
reset_cache_connection(alias)
|
delete(*args, **kwargs)
Delete the tenant and instruct the configured backend to remove resources.
After the database record is removed the tenant backend is asked to
perform any required cleanup (for example dropping a schema or
removing an external database). The method returns the result of
super().delete().
Source code in django_omnitenant/models.py
206
207
208
209
210
211
212
213
214
215
216
217
218 | def delete(self, *args, **kwargs):
"""Delete the tenant and instruct the configured backend to remove resources.
After the database record is removed the tenant backend is asked to
perform any required cleanup (for example dropping a schema or
removing an external database). The method returns the result of
``super().delete()``.
"""
result = super().delete(*args, **kwargs)
backend = get_tenant_backend(self)
backend.delete()
return result
|
BaseDomain
Bases: Model
Abstract model mapping a tenant to a DNS-style domain name.
Subclass this model to provide tenant-to-domain mappings used by
resolvers that identify tenants from host headers. The model stores
a one-to-one relation to the configured tenant model and a unique
domain string which must be a valid DNS name.
Source code in django_omnitenant/models.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250 | class BaseDomain(models.Model):
"""Abstract model mapping a tenant to a DNS-style domain name.
Subclass this model to provide tenant-to-domain mappings used by
resolvers that identify tenants from host headers. The model stores
a one-to-one relation to the configured tenant model and a unique
domain string which must be a valid DNS name.
"""
tenant = models.OneToOneField(
settings.TENANT_MODEL,
on_delete=models.CASCADE,
help_text="The tenant this domain belongs to.",
)
domain = models.CharField(
unique=True,
validators=[validate_domain_name],
help_text="Must be a valid DNS label (RFC 1034/1035).",
)
objects: TenantQuerySetManager = TenantQuerySetManager()
def __str__(self):
"""Return a compact representation showing tenant and domain."""
return f"{str(self.tenant)} => {self.domain}"
class Meta:
abstract = True
unique_together = ("tenant", "domain")
|
__str__()
Return a compact representation showing tenant and domain.
Source code in django_omnitenant/models.py
| def __str__(self):
"""Return a compact representation showing tenant and domain."""
return f"{str(self.tenant)} => {self.domain}"
|