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.io→tenant_acme)
The middleware sets request.tenant and activates the PostgreSQL search_path for the resolved schema.
Database Router¶
The router ensures:
- Shared-app models (Tenant, ContentType) always query from the
publicschema - 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:
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:
- ORM-level: The database router directs all queries to the correct schema
- Middleware-level: Every request has a tenant context set before any view code runs
- Task-level:
TenantTaskbase class activates the schema before Celery task execution - Test-level: An autouse
tenant_schemafixture 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¶
- Multi-Tenancy --- Architecture details
- Authentication & Authorization --- Per-tenant RBAC
- Database Management --- Schema migration operations