Skip to content

Models

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
154
155
156
157
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:

  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.

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
243
244
245
246
def __str__(self):
    """Return a compact representation showing tenant and domain."""

    return f"{str(self.tenant)} => {self.domain}"