Skip to content

Tenant Isolation

PostgreSQL schema-per-tenant isolation ensures complete data separation between tenants.

Three-Schema Architecture

Schema Type Apps Purpose
public Shared django_pgschemas, apps.tenants, contenttypes Tenant registry, content types
main Static tenant Django contrib, third-party, apps.main, apps.users LMS home site (admin, allauth)
tenant_{slug} Dynamic tenant Django contrib, third-party, apps.users, all LMS apps Per-tenant business data

Each dynamic tenant gets its own PostgreSQL schema (tenant_acme, tenant_demo, etc.) with a complete set of tables. There is no shared business data between tenants.

How Isolation Works

Domain Routing

DomainRoutingMiddleware (from django-pgschemas) resolves the tenant from the request domain:

  • Main schema: Resolves via FALLBACK_DOMAINS (e.g., localhost, app.lendsmart.io)
  • Tenant schemas: Resolve via subdomain (e.g., acme.app.lendsmart.iotenant_acme)

The middleware sets request.tenant and activates the PostgreSQL search_path for the resolved schema.

Database Router

DATABASE_ROUTERS = ("django_pgschemas.routers.TenantAppsRouter",)

The router ensures:

  • Shared-app models (Tenant, ContentType) always query from the public schema
  • Tenant-app models query from the active tenant schema
  • No cross-schema queries are possible through the ORM

Warning

The database router is required. Without it, shared-app tables get created in tenant schemas, causing FK violations and data isolation bugs.

Search Path

When a tenant is active, PostgreSQL's search_path is set to:

SET search_path TO tenant_acme, public;

This means tenant queries hit tenant_acme tables first, with public as fallback for shared tables (content types).

Tenant Status Enforcement

TenantStatusMiddleware runs after domain routing and blocks requests to non-active tenants:

Tenant Status Result
active Request proceeds
suspended 403 Forbidden — "Tenant is suspended."
deleted 403 Forbidden — "Tenant not found."

This middleware ensures suspended tenants cannot access any API endpoints or the portal.

Tenant Model

The Tenant model in apps/tenants/models.py stores per-tenant configuration:

Field Type Purpose
name CharField Display name
slug SlugField (unique) URL-safe identifier, used in schema name
status TextChoices active, suspended, deleted
locale CharField Default locale (e.g., en, es)
timezone TimeZoneField IANA timezone (e.g., America/New_York)
default_currency CharField ISO 4217 code (e.g., USD)
settings JSONBField Extensible tenant configuration

Setting auto_create_schema = True on the model triggers automatic PostgreSQL schema creation when a new tenant is created.

Cross-Schema Prevention

Several mechanisms prevent accidental cross-schema access:

  1. ORM-level: The database router directs all queries to the correct schema
  2. Middleware-level: Every request has a tenant context set before any view code runs
  3. Task-level: TenantTask base class activates the schema before Celery task execution
  4. Test-level: An autouse tenant_schema fixture activates the test tenant schema for every test

Note

Cross-schema queries are not supported. Use the public schema for shared lookups (tenant registry, content types).

Schema Management Commands

Command Purpose
migrateschema -as Migrate all schemas (public + static + dynamic)
migrateschema -ds Migrate only dynamic tenant schemas
migrateschema -ss Migrate only static schemas (public, main)
runschema <cmd> -s tenant_acme Run a command on a specific tenant
whowill -ds Preview which schemas a command would target

See Also