We detected you are likely not from a Russian-speaking region. Would you like to switch to the international version of the site?

К списку статей

Мульти‑тенантные SaaS‑платформы на Django и Symfony: схемы изоляции данных, миграции схем и динамический роутинг запросов

Мульти‑тенантная 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);
  • мониторинг ошибок авторизации и попыток доступа к чужим ресурсам;
  • контроль миграций: кто запускал, какие версии, где завершилось/упало.

Логирование персональных данных следует исключать по принципу минимизации. В логи разумно писать идентификаторы и метаданные, а не содержимое документов/полей.

План внедрения: от выбора схемы к эксплуатации

  1. Определите требуемую изоляцию: сегменты данных по чувствительности.
  2. Выберите схему: tenant_id+RLS / schema-per-tenant / database-per-tenant / гибрид.
  3. Зафиксируйте tenant resolution: subdomain или путь; реализуйте middleware/listener в обоих фреймворках.
  4. Усильте изоляцию на уровне БД: constraints/RLS при row-level; search_path при schema; отдельные соединения при database-per-tenant.
  5. Спроектируйте миграции: единый процесс, таблицы состояния, rollout по партиям тензантов.
  6. Подготовьте аудит и метрики: расследование инцидентов, контроль попыток межтенантного доступа.
  7. Протестируйте сценарии “края”: смена tenant в одной сессии, повторные запросы, масштабирование пула соединений.
  8. Организуйте DR/backup с учетом выбранной схемы (особенно для schema/database-per-tenant).

Заключение

Мульти‑тенантность в связке Django и Symfony — это управляемая сложность, если заранее определить модель изоляции данных, построить надежный tenant resolution и “закрепить” безопасность на уровне БД. Миграции схем требуют отдельного дисциплинированного процесса с версионированием и управляемым rollout. Динамический роутинг должен быть предсказуемым и request-scoped, а наблюдаемость и аудит — обязательными компонентами архитектуры для соответствия требованиям защиты данных в РФ.

Если вам нужна практическая реализация такой архитектуры под ваш бизнес и инфраструктуру (включая миграции, RLS, schema/database-per-tenant и безопасный роутинг), команда РыбинскЛАБ поможет спроектировать и внедрить решение с учетом требований законодательства РФ.

Упомянем услуги: РыбинскЛАБ выполняет разработку и внедрение SaaS‑платформ, включая мульти‑тенант архитектуру, интеграцию Django/Symfony, проектирование схем БД и миграционных пайплайнов.

Материал подготовлен и отредактирован для практического применения. Перед внедрением в продакшен проверьте код и команды на своём окружении.

Поделиться материалом

Нужна сложная backend-разработка?

Проектирование архитектуры, PHP/Python backend, интеграции API, боты, автоматизация и оптимизация существующих систем.

Обсудить проект
Поддержать проект