
Мульти‑тенант (multi‑tenant) — это когда одна платформа обслуживает много компаний («арендаторов») с полной изоляцией их данных. Для бизнеса это:
Сигналы, что пора переходить:
Есть четыре популярных варианта. Кратко про плюсы/минусы и когда брать какой.
Ниже разберём практичный путь: общая схема + RLS. Это хорошо масштабируется, просто поддерживается и даёт сильные гарантии изоляции.
Идея: у каждой бизнес‑строки есть колонка tenant_id. База сама не отдаст ничего, кроме строк текущего арендатора — благодаря RLS‑политике.
-- 1) Пространство имён для служебных объектов
CREATE SCHEMA IF NOT EXISTS app;
-- 2) Таблица арендаторов
CREATE TABLE IF NOT EXISTS app.tenants (
id uuid PRIMARY KEY,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
active boolean NOT NULL DEFAULT true
);
-- 3) Утилита: получить текущего арендатора из параметра сессии
CREATE OR REPLACE FUNCTION app.current_tenant() RETURNS uuid
LANGUAGE sql STABLE AS $$
SELECT NULLIF(current_setting('app.tenant_id', true), '')::uuid
$$;
-- 4) Пример бизнес-таблицы
CREATE TABLE IF NOT EXISTS public.projects (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL REFERENCES app.tenants(id),
slug text NOT NULL,
title text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 5) Уникальность внутри арендатора
CREATE UNIQUE INDEX IF NOT EXISTS projects_tenant_slug_uidx
ON public.projects(tenant_id, slug);
-- 6) Включаем RLS и задаём политику
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY IF NOT EXISTS projects_isolation ON public.projects
USING (tenant_id = app.current_tenant())
WITH CHECK (tenant_id = app.current_tenant());
-- 7) Защита от "забыли проставить tenant_id"
CREATE OR REPLACE FUNCTION app.fill_tenant_id() RETURNS trigger AS $$
BEGIN
IF NEW.tenant_id IS NULL THEN
NEW.tenant_id := app.current_tenant();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_projects_fill_tenant ON public.projects;
CREATE TRIGGER trg_projects_fill_tenant
BEFORE INSERT ON public.projects
FOR EACH ROW EXECUTE FUNCTION app.fill_tenant_id();
Что это даёт:
Важно: приложение должно устанавливать параметр сессии app.tenant_id при каждом запросе к БД.
Мы считаем, что арендатор определяется по домену вида customer.example.com или по заголовку X-Tenant-Id (только от вашего обратного прокси), а не приходит «честным словом» из браузера.
# middleware.py
import uuid
from contextvars import ContextVar
from django.db import connection
from django.http import HttpResponseForbidden
from django.utils.deprecation import MiddlewareMixin
_current_tenant: ContextVar[str | None] = ContextVar("current_tenant", default=None)
TRUSTED_HEADER = "HTTP_X_TENANT_ID" # приходит от вашего Nginx/Traefik, а не из JS
class TenantMiddleware(MiddlewareMixin):
def process_request(self, request):
# 1) Берём tenant из поддомена или доверенного заголовка
host = request.get_host().split(":")[0]
sub = host.split(".")[0]
tenant_id = None
if sub and sub not in ("www", "app"): # простой разбор поддомена
# В проде тут обычно маппинг поддомена к tenant_id в памяти/кэше/БД
# Для примера считаем, что поддомен — это UUID
try:
uuid.UUID(sub)
tenant_id = sub
except Exception:
pass
if not tenant_id:
tenant_id = request.META.get(TRUSTED_HEADER)
if not tenant_id:
return HttpResponseForbidden("Tenant is required")
# 2) Сохраняем в контекст запроса
request.tenant_id = tenant_id
_current_tenant.set(tenant_id)
# 3) Настраиваем параметр сессии Postgres для RLS
with connection.cursor() as cur:
cur.execute("SELECT set_config('app.tenant_id', %s, false)", [tenant_id])
def process_response(self, request, response):
_current_tenant.set(None)
return response
# Хелпер для фоновых задач
def get_current_tenant() -> str | None:
return _current_tenant.get()
# models.py
import uuid
from django.db import models
from django.db.models import Manager
from .middleware import get_current_tenant
class TenantQuerySet(models.QuerySet):
def _for_tenant(self):
tenant = get_current_tenant()
if tenant is None:
return self
return self.filter(tenant_id=tenant)
# Автоматически добавляем фильтр к самым частым операциям
def all(self):
return super().all()._for_tenant()
def filter(self, *args, **kwargs):
qs = super().filter(*args, **kwargs)
return qs._for_tenant()
class TenantManager(Manager.from_queryset(TenantQuerySet)):
pass
class Project(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
tenant_id = models.UUIDField()
slug = models.SlugField()
title = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
objects = TenantManager()
class Meta:
constraints = [
models.UniqueConstraint(fields=["tenant_id", "slug"], name="projects_tenant_slug_uidx_py"),
]
Здесь RLS — главный «охранник», а менеджер — подстраховка, чтобы при обычных вызовах ORM мы не забыли про фильтр.
# views.py
from django.http import JsonResponse, HttpResponseBadRequest
from .models import Project
from .middleware import get_current_tenant
def create_project(request):
if request.method != "POST":
return HttpResponseBadRequest("POST only")
tenant_id = get_current_tenant()
title = request.POST.get("title")
slug = request.POST.get("slug")
project = Project.objects.create(tenant_id=tenant_id, title=title, slug=slug)
return JsonResponse({"id": str(project.id), "slug": project.slug})
Ключевая идея: сервер сам определяет текущего арендатора и проставляет tenant_id, не верит данным из браузера.
Минимальный набор проверок: чужие данные не видны, нельзя записать c чужим tenant_id, фоновые задачи не «забывают» контекст.
# tests/test_tenant_isolation.py
import uuid
import pytest
from django.db import connection
from app.models import Project
@pytest.mark.django_db
def test_rls_isolation(client):
t1, t2 = str(uuid.uuid4()), str(uuid.uuid4())
# Имитируем два запроса от разных арендаторов
def set_tenant(t):
with connection.cursor() as cur:
cur.execute("SELECT set_config('app.tenant_id', %s, false)", [t])
set_tenant(t1)
p1 = Project.objects.create(tenant_id=t1, title="A", slug="a")
set_tenant(t2)
p2 = Project.objects.create(tenant_id=t2, title="B", slug="b")
# В сессии t2 мы не видим записи t1
assert list(Project.objects.filter(slug="a")) == []
# Возврат в t1 — видим свою запись
set_tenant(t1)
assert Project.objects.get(slug="a").id == p1.id
# Попытка записать "чужой" tenant сквозь RLS провалится
with pytest.raises(Exception):
Project.objects.create(tenant_id=t2, title="hack", slug="h")
Изоляция между арендаторами — это половина дела. Внутри каждого арендатора нужны роли:
Модель RBAC храните в таблицах users, user_roles, role_permissions с tenant_id. Проверки прав — в сервисах/декораторах, кэшируйте пермишены. В CRUD‑слое старайтесь проводить проверку до похода в БД (но не вместо RLS).
Пример обёртки над задачей:
# tasks.py
from celery import shared_task
from django.db import connection
@shared_task(bind=True)
def tenant_task(self, tenant_id: str, payload: dict):
# Устанавливаем контекст арендатора для RLS
with connection.cursor() as cur:
cur.execute("SELECT set_config('app.tenant_id', %s, false)", [tenant_id])
# ... дальше ваша логика
# Все ORM-запросы будут изолированы этим tenant_id
Мульти‑тенант — это не про «сэкономить на серверах любой ценой». Это про управляемую масштабируемость: единая кодовая база, строгая изоляция данных и дешёвое сопровождение. PostgreSQL с Row Level Security даёт сильные гарантии на уровне самой базы, а легкие паттерны в Django позволяют аккуратно передавать контекст арендатора в HTTP и фоновые задачи. Добавьте индексы, кэш с префиксами, экспорт данных и роли — и платформа готова к росту без хаоса и утечек.