Мульти‑тенантная SaaS‑платформа позволяет обслуживать множество клиентов в рамках одной системы. Однако архитектурные решения напрямую влияют на безопасность данных, управляемость миграций, отказоустойчивость и стоимость эксплуатации. Для стеков Django (Python) и Symfony (PHP) особенно важны: единый контракт идентификации тензанта, консистентные политики доступа на уровне БД, предсказуемые миграции и корректный динамический роутинг запросов.
Ниже — практическое руководство по схемам изоляции данных, миграциям схем и динамическому роутингу запросов с учетом актуальных требований законодательства РФ, применимых к обработке персональных данных и управлению доступами (в части общих принципов: минимизация данных, защита, учет и контроль доступа, аудит действий).
Требования РФ и принципы комплаенса в мульти‑тенант архитектуре
Для большинства SaaS сценариев актуальны общие требования к защите информации и персональных данных. На практике это означает, что архитектура должна:
- обеспечивать строгую изоляцию данных клиентов (чтобы исключить несанкционированный доступ и утечки между тензантами);
- минимизировать объемы передаваемых/хранимых персональных данных и ограничивать их использование;
- вести журналы доступа и действий (аудит), поддерживать расследование инцидентов;
- использовать контроль доступа на серверной стороне, а не только на уровне интерфейса;
- обеспечивать защищенные каналы связи и управление секретами;
- поддерживать управляемость миграций и откат/восстановление без потери целостности данных.
При проектировании важно закладывать эти принципы в модели данных, роутинг, слой авторизации и в процесс эксплуатации (DevOps/DBA-процедуры).
Идентификация тензанта: где и как определять tenant
Ключевой момент — определить тензант для каждого запроса одинаково в Django и Symfony. Наиболее распространенные варианты:
- Subdomain: tenant1.example.com / tenant2.example.com;
- Путь: example.com/tenants/{tenantSlug}/...;
- Заголовок: X-Tenant-ID (обычно менее предпочтителен для публичных UI, но допустим для внутренних API при наличии надежного шлюза).
Рекомендуемая схема:
- тензант извлекается из маршрута/хоста;
- далее выполняется валидация (существование тензанта, статус, ограничения);
- формируется контекст запроса (tenant_id/tenant_uuid) в request scope;
- выполняется авторизация на основе tenant_id и роли пользователя.
Чтобы исключить “утечку” контекста, контекст должен быть request-scoped, а не глобальным.
Схемы изоляции данных: 4 практичных подхода
Изоляция данных — основной выбор, влияющий на безопасность и эксплуатацию. Ниже — 4 типовых архитектурных варианта.
1) Общая БД и общий набор таблиц + tenant_id (row-level isolation)
Все тензанты используют одну схему БД, данные размечаются полем tenant_id, а чтение/запись ограничиваются фильтрацией по tenant_id.
Плюсы:
- простота деплоя;
- централизованные миграции;
- легче строить аналитику “по всем клиентам”.
Минусы:
- повышенные риски ошибки в фильтрации (если где-то забыли tenant_id);
- сложнее гарантировать изоляцию без усиления на уровне БД (RLS, constraints);
- индексы и объем данных могут разрастись.
Рекомендация по усилению безопасности: использовать ограничения на уровне БД (FK, NOT NULL), индексы по (tenant_id, ...), а при PostgreSQL — Row Level Security (RLS) для жесткого запрета “вырваться” за tenant.
-- PostgreSQL: пример RLS-ограничения
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
CREATE POLICY invoices_tenant_isolation
ON invoices
FOR ALL
USING (tenant_id = current_setting('app.tenant_id')::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);
Далее в приложении перед запросом устанавливается параметр:
-- Важно: выполнить в рамках транзакции/соединения
SELECT set_config('app.tenant_id', :tenant_uuid, true);
2) Отдельная схема (schema-per-tenant) в одной БД
Каждый тензант имеет свою схему (namespace) в рамках одной БД. Таблицы одинаковы по структуре, но находятся в разных схемах.
Плюсы:
- сильнее изоляция на уровне структуры;
- можно применять разные политики/индексы точнее под клиента.
Минусы:
- усложнение миграций (нужно применять к каждой схеме);
- динамическая смена search_path/схемы;
- большое количество тензантов увеличивает нагрузку на управление схемами.
Технически часто используют динамическую настройку search_path или явное указание схемы в запросах.
-- PostgreSQL: смена search_path для текущей сессии
SET search_path TO tenant_schema_name, public;
3) Отдельная БД на тензант (database-per-tenant)
Для каждого клиента выделяется собственная база (кластер/инстанс или логическая БД). Это максимальный уровень изоляции.
Плюсы:
- наиболее безопасная изоляция;
- простая “tenant backup/restore”;
- возможность разных версий/параметров по клиентам.
Минусы:
- сложность миграций и оркестрации;
- увеличенная стоимость инфраструктуры;
- нужен надежный service discovery/хранилище конфигураций.
Архитектурный паттерн: центральный “catalog” БД хранит tenant->connection info, а приложение при запросе выбирает корректное подключение.
4) Гибрид: row-level + “критичные” таблицы в отдельных схемах/БД
Если часть данных действительно критична (например, финансовые документы, персональные данные клиентов), можно выделить их в отдельную область хранения (отдельная схема/БД), а менее критичные — хранить в общей таблице с tenant_id.
Это снижает стоимость, сохраняя безопасность для наиболее чувствительных сущностей. Но требует четкого доменного дизайна и согласованной модели идентификаторов.
Рекомендация по выбору схемы
- Начинаете и тензантов немного: row-level isolation (tenant_id) + RLS/constraints — обычно лучший старт по стоимости.
- Нужна сильная изоляция без выделения БД: schema-per-tenant.
- Есть требования крупных enterprise-клиентов: database-per-tenant.
- Сложный комплаенс по части данных: гибридный подход.
Миграции схем: как делать безопасно и предсказуемо
Миграции в мульти‑тенант системах — не просто техническая задача. Это зона повышенного риска: можно сломать часть клиентов, создать рассинхронизацию или повредить данные.
Ниже — общий процесс, применимый и к Django, и к Symfony.
1) Общие принципы миграций
- Версионирование схем и единый механизм “какая миграция применена” по тензанту.
- Idempotency (миграции должны допускать повторный запуск при необходимости).
- Пошаговый rollout: сначала на staging, затем на подмножество тензантов.
- Совместимость чтения/записи: сначала добавляем поля/таблицы в “старом” формате, затем включаем новое поведение приложения.
- Транзакционность там, где возможно; контроль блокировок и таймаутов.
- Audit: фиксировать, кто/когда/что применил (журнал миграций).
2) Миграции для row-level isolation (tenant_id)
Поскольку схема общая, миграции структуры выполняются один раз для всей БД. Дополнительная работа — обеспечивать корректность данных (backfill tenant_id, заполнение новых полей) и проверять индексы.
Важно обеспечить “нулевое” поведение: до релиза кода, использующего новые поля, миграция должна быть безопасной для старого кода (например, поля nullable или с дефолтами).
3) Миграции для schema-per-tenant
Нужно пройти по списку схем и применить миграции к каждой. Часто делают “миграционный командный раннер”, который:
- получает список tenant-schema;
- для каждого тензанта переключает search_path (или указывает schema в ORM);
- выполняет миграции;
- фиксирует прогресс в таблице migration_state по каждому tenant.
Пример высокоуровневого подхода (концептуально):
for tenant in tenants:
with db_session(schema=tenant.schema_name):
run_schema_migrations()
mark_migration_done(tenant.id, migration_version)
Критично: контролировать параллелизм (можно не более N схем одновременно), чтобы не создать нагрузку на систему миграций и метаданных.
4) Миграции для database-per-tenant
Аналогично, но вместо смены схемы выбирается соединение с отдельной БД. При большом количестве БД важно ограничивать число параллельных подключений и учитывать сетевые задержки.
Также полезно поддерживать стратегию “готовности”:
- метка “database ready” для тензанта;
- чтение запросов маршрутизируется только на БД после миграций;
- если миграции еще идут — сервер возвращает контролируемый статус (или переключает на read-only режим) в зависимости от бизнес-требований.
Контроль доступа: как гарантировать изоляцию даже при ошибках в коде
Даже идеальный роутинг не заменяет серверные гарантии. Поэтому механизмы контроля доступа должны быть встроены в каждый слой:
- ORM-уровень: всегда фильтровать query по tenant_id (или выбирать нужную схему/DB).
- БД-уровень: RLS/constraints/отдельные схемы/DB.
- Сервисный слой: сервисы должны принимать tenant_id как обязательный параметр, не извлекать его “глобально” из переменных.
- API-слой: авторизация до обработки доменных действий.
Это снижает риск межтенантных утечек — самый критичный класс инцидентов.
Динамический роутинг запросов: единая стратегия для Django и Symfony
Динамический роутинг решает задачу: по входящему запросу определить tenant и направить обработку в корректный контекст (БД/схема/feature flags).
Вариант A: tenant resolution до обработки контроллеров
Общий паттерн:
- middleware (в Django) и event listener (в Symfony) извлекают tenant из host/path;
- подготавливают контекст (tenant_id, schema/db target);
- устанавливают параметр в БД (для RLS) или переключают search_path/connection.
Django (пример middleware tenant resolution)
# middleware.py
from django.utils.deprecation import MiddlewareMixin
class TenantMiddleware(MiddlewareMixin):
def process_request(self, request):
host = request.get_host().split(':')[0]
tenant_slug = host.split('.')[0] if host.endswith('example.com') else None
# 1) валидируем и находим tenant
# tenant = Tenant.objects.get(slug=tenant_slug, is_active=True)
request.tenant_id = '...tenant-uuid...'
request.tenant_schema = 'tenant_schema_name'
# 2) если используется RLS: set_config('app.tenant_id', ...)
# request.tenant_db_connection.execute("SELECT set_config(...)")
return None
Дополнительно стоит:
- обработать случаи отсутствия/невалидности тензанта;
- не допускать кэширования, где tenant контекст может “перемешаться” между запросами;
- обеспечить логирование с tenant_id (но без утечки персональных данных в логи).
Symfony (пример listener tenant resolution)
// src/EventSubscriber/TenantSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
class TenantSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
RequestEvent::class => [['onRequest', 20]],
];
}
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$host = $request->getHost();
// Пример: tenant1.example.com
$parts = explode('.', $host);
$tenantSlug = $parts[0] ?? null;
// 1) найти tenant по slug
// $tenant = $this->tenantRepo->findActiveBySlug($tenantSlug);
// 2) положить в request attributes/context
$request->attributes->set('tenant_id', '...tenant-uuid...');
$request->attributes->set('tenant_schema', 'tenant_schema_name');
// 3) при RLS можно выполнить set_config в рамках соединения
// $conn->executeQuery("SELECT set_config('app.tenant_id', ?, true)", [$tenant->getUuid()]);
}
}
Вариант B: роутинг на уровне API Gateway / Reverse Proxy
Если у вас есть API Gateway, можно определить tenant на раннем слое (NGINX/Envoy) и прокинуть его в заголовке/метаданных. Тогда и Django, и Symfony получают готовый контекст.
Важно: заголовки должны быть защищены от подмены (только шлюз может их проставлять). Иначе компрометация шлюза/клиентского запроса приведет к межтенантной утечке.
Техники динамической смены БД/схемы в приложении
В зависимости от выбранной схемы изоляции используются разные техники:
- row-level + RLS: перед запросом установить current_setting(app.tenant_id).
- schema-per-tenant: установить search_path (или использовать ORM-опции schema).
- database-per-tenant: выбрать connection по tenant_id и подменить настройки провайдера/репозитория.
Нужно помнить, что ORM-сессии и пул соединений могут “нести” предыдущий контекст. Поэтому параметр tenant_id/search_path должен устанавливаться для каждого запроса/соединения, либо использовать отдельные connection per tenant (обычно ограниченно) или аккуратную “reset” логику.
Единый доменный контракт между Django и Symfony
Если часть функциональности вынесена в Django, а часть в Symfony, важно зафиксировать:
- формат tenant_id (UUID),
- правила slug->tenant mapping,
- модель ролей и прав (например, RBAC/ABAC),
- формат аудита событий и корреляционные идентификаторы (request_id, trace_id).
Для межсервисных вызовов полезно передавать tenant контекст в виде неизменяемого значения и подписывать запросы (если предусмотрено) или использовать mTLS/secure gateway.
Наблюдаемость и аудит: минимально необходимое для комплаенса
Для мульти‑тенант SaaS особое значение имеют:
- журналы доступа к API с tenant_id и user_id;
- журналы изменений (audit trail) по чувствительным сущностям;
- корреляция инцидентов (trace_id/request_id);
- мониторинг ошибок авторизации и попыток доступа к чужим ресурсам;
- контроль миграций: кто запускал, какие версии, где завершилось/упало.
Логирование персональных данных следует исключать по принципу минимизации. В логи разумно писать идентификаторы и метаданные, а не содержимое документов/полей.
План внедрения: от выбора схемы к эксплуатации
- Определите требуемую изоляцию: сегменты данных по чувствительности.
- Выберите схему: tenant_id+RLS / schema-per-tenant / database-per-tenant / гибрид.
- Зафиксируйте tenant resolution: subdomain или путь; реализуйте middleware/listener в обоих фреймворках.
- Усильте изоляцию на уровне БД: constraints/RLS при row-level; search_path при schema; отдельные соединения при database-per-tenant.
- Спроектируйте миграции: единый процесс, таблицы состояния, rollout по партиям тензантов.
- Подготовьте аудит и метрики: расследование инцидентов, контроль попыток межтенантного доступа.
- Протестируйте сценарии “края”: смена tenant в одной сессии, повторные запросы, масштабирование пула соединений.
- Организуйте DR/backup с учетом выбранной схемы (особенно для schema/database-per-tenant).
Заключение
Мульти‑тенантность в связке Django и Symfony — это управляемая сложность, если заранее определить модель изоляции данных, построить надежный tenant resolution и “закрепить” безопасность на уровне БД. Миграции схем требуют отдельного дисциплинированного процесса с версионированием и управляемым rollout. Динамический роутинг должен быть предсказуемым и request-scoped, а наблюдаемость и аудит — обязательными компонентами архитектуры для соответствия требованиям защиты данных в РФ.
Если вам нужна практическая реализация такой архитектуры под ваш бизнес и инфраструктуру (включая миграции, RLS, schema/database-per-tenant и безопасный роутинг), команда РыбинскЛАБ поможет спроектировать и внедрить решение с учетом требований законодательства РФ.
Упомянем услуги: РыбинскЛАБ выполняет разработку и внедрение SaaS‑платформ, включая мульти‑тенант архитектуру, интеграцию Django/Symfony, проектирование схем БД и миграционных пайплайнов.