Skip to content

ТЗ — Мультитенантность (cms/multitenancy)

Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — (референс stancl/tenancy) Статус: ТЗ к разработке

Назначение и возможности

Изоляция данных по тенанту на уровне БД (отдельная схема PostgreSQL или префикс таблиц) — в отличие от cms/multisite, где данные общие и разделены только измерением site_id. Используется, когда сайты принадлежат разным клиентам и данные не должны пересекаться даже теоретически. Референс — модель stancl/tenancy.

  • Провижининг нового тенанта: схема БД, миграции, дефолтные данные;
  • резолвинг тенанта по домену на раннем этапе запроса (до подключения к БД);
  • переключение соединения БД на схему тенанта (tenancy()->initialize());
  • изоляция очередей/кеша по тенанту (префиксы ключей, отдельные Horizon-очереди);
  • удаление тенанта (снос схемы) с обязательным предварительным бэкапом;
  • список тенантов и их статус (активен/приостановлен) в центральной админке.

Зависимости и выключение

requires: — · provides: multitenancy · conflicts: cms/multisite — совместное включение требует явного выбора режима изоляции (документированное решение, не автоматический выбор ядра); по умолчанию оба модуля вместе не активируются.

При выключении модуль не может быть отключён на установке с уже провизионированными тенантами без миграции данных обратно в общую схему — деградация здесь равносильна блокировке отключения до ручной миграции (health-чек предупреждает заранее).

Модель данных

ТаблицаКлючевые поляПримечание
cms_tenantsid, domain, schema_name, status, provisioned_atЦентральная таблица (в public-схеме)
cms_tenant_domainstenant_id, domainДомены/поддомены тенанта

Данные самого тенанта живут в отдельной PG-схеме (tenant_{id}) со своей копией таблиц ядра — вне зоны видимости этого файла (создаются миграциями ядра при провижининге). status — enum → PHP Enum (active/suspended); tenant_id в cms_tenant_domainsconstrained('cms_tenants')->index(); domain — уникальный индекс в обеих таблицах.

Входные и выходные данные

Входы:

ИсточникДанные/поляЧем валидируется
Filament-форма провижининга (studio)domainFormRequest: required|string|max:255, уникален в cms_tenants/cms_tenant_domains, тот же валидатор домена, что и у API
API POST /tenantsdomainFormRequest + ability multitenancy.manage; провижининг ставится в очередь, не выполняется синхронно
API DELETE /tenants/{id}id, подтверждение бэкапаFormRequest + require_backup_before_delete: без успешного предварительного бэкапа запрос отклоняется 422
Команда cms:multitenancy:provision {domain} --jsondomain (CLI-аргумент)тот же сервис-валидатор домена, что у формы/API (общий код, не дублирование правил)
Входящий HTTP-запрос на публичный домензаголовок Hostmiddleware резолвинга: точное совпадение строки в cms_tenant_domains, без wildcard/regex по Host

Всё, что не входит в этот список (произвольные query-параметры, поля вне domain/подтверждения бэкапа), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.

Выходы:

ПотребительДанныеФормат
Ядро — подключение к БДрезолвленные tenant_id/schema_nameprovides-контракт multitenancy (канал 3), внутренний вызов, не HTTP
API GET /tenantsid, domain, status, provisioned_atконверт {data, meta}, keyset-пагинация
Filament (список тенантов)id, domain, status, provisioned_atтаблица с фильтром по статусу
cms:multitenancy:provision --jsontenant_id, schema_name, statusJSON
События TenantProvisioned/TenantSuspended/TenantDeletedсм. «События и обмен»внутренняя шина, канал 1

Настройки (группа multitenancy)

КлючТипДефолтaffectsPageCacheОписание
multitenancy.schema_prefixstring"tenant_"Префикс имени схемы при провижининге
multitenancy.auto_provision_on_signupboolfalseАвтосоздание тенанта при регистрации
multitenancy.queue_isolationbooltrueОтдельные очереди Horizon на тенанта
multitenancy.max_tenantsint100Квота инсталляции: максимум тенантов на установку (0 = без лимита)
multitenancy.tenant_quota_disk_mbint5000Квота тенанта: диск на схему, МБ (0 = без лимита; не путать с max_tenants — той квотируется число тенантов, этой — ресурс каждого)
multitenancy.tenant_quota_pagesint10000Квота тенанта: максимум страниц внутри его схемы
multitenancy.tenant_quota_usersint50Квота тенанта: максимум пользователей внутри его схемы
multitenancy.deletion_grace_period_minutesint15Дожитие активных запросов/соединений тенанта перед физическим сносом схемы
multitenancy.require_backup_before_deletebooltrueЗапрещать снос схемы без успешного предварительного бэкапа (kill-switch только для dev-инсталляций)
multitenancy.tenant_registry_cache_ttlint0TTL кеша реестра доменов, сек. (0 = кеш живёт до инвалидации событием — рекомендуемый режим)
multitenancy.tenant_db_query_rate_limitint200Лимит запросов/сек к БД на схему тенанта (pgbouncer/лимитер соединений) — защита от «шумного соседа» (0 = без лимита, не рекомендуется на многоарендных установках)

API

МетодПутьДоступНазначение
GET/api/v1/admin/multitenancy/tenantsstudio-рольСписок тенантов установки (keyset-пагинация)
POST/api/v1/admin/multitenancy/tenantsstudio-рольПровижининг нового тенанта (job, не синхронно)
DELETE/api/v1/admin/multitenancy/tenants/{id}studio-рольСнос тенанта (с обязательным бэкапом)

Компоненты

Filament: ресурс тенантов (статус, домен, дата провижининга), только под studio-ролью. Команды: cms:multitenancy:provision {domain} --json, cms:multitenancy:migrate --json (прогон миграций по всем схемам тенантов), cms:multitenancy:doctor --json.

Демо-контент. Своего блока/виджета для галереи нет — демонстрировать нечего, кроме механизма изоляции. Замена демо-сидера: playground/_gallery профиль обязан штатно подниматься без единого тенанта (пустая установка — штатный режим, «Крайние случаи»); отдельный сидер demo-тенантов не требуется.

События и обмен

СобытиеКогдаPayload
TenantProvisionedСхема создана и мигрированаtenant_id, domain, schema_name
TenantSuspendedТенант приостановлен (не удалён)tenant_id
TenantDeletedСхема снесена после бэкапаtenant_id, backup_path

Реализует канонический контракт multitenancy — потребляется ядром на этапе подключения к БД (до резолвинга остальных сервисов). Взаимоисключающая связь с cms/multisite фиксируется в манифесте (conflicts), а не проверяется рантаймом через события. FilterBus не используется. Слушает: только собственные HTTP-запросы (middleware резолвинга тенанта по домену).

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
Ядро — подключение к БДprovides-контракт multitenancyoutядро резолвит tenant_id по домену и переключает схему до инициализации остальных сервисов и до RequestContext
Ядро — миграциисервис-вызов (внутренний, часть провижининга)outпровижининг прогоняет миграции ядра и модулей в новой схеме тенанта
cms/multisiteconflicts (манифест, не рантайм-канал)взаимоисключение фиксируется декларативно; проверка — на этапе enable, не событием
Очередь multitenancyочередьoutпровижининг и снос тенанта выполняются джобами, не синхронно из HTTP/команды
cms/healthhealth-чек из манифестаoutсверка версии миграций во всех схемах тенантов, доступность реестра доменов
Filament (studio)UI-компонент модуля, не отдельный каналсписок/управление тенантами через сервис модуля, без raw SQL

Фоновая работа

Очередь multitenancy: джобы провижининга и сноса тенанта — обе идемпотентны (повтор провижининга на уже существующей схеме не дублирует эффект, повторный снос без схемы завершается успехом без ошибки). Прогон миграций по всем схемам (cms:multitenancy:migrate) — тоже через очередь при большом числе тенантов, не синхронно из команды. Джоба агрегации квот тенанта (диск/страницы/пользователи, см. «Настройки») — по расписанию через ScheduleRegistrar ядра, детали учёта — «Производительность и кеш».

Мини-ранбук (§15 стандарта):

СимптомЧто проверитьЧем чинится
Схема тенанта рассинхронизирована по миграциям (health-чек красный)cms:multitenancy:doctor --json — схемы с версией миграций ниже ожидаемойcms:multitenancy:migrate --json точечно по конкретной schema_name, не по всем
Canary-запрос doctor'а детектирует чужую схему (search_path ≠ ожидаемой)критичный алерт cms/health («Крайние случаи», первый сценарий)соединение сбрасывается немедленно (не отдаёт ответ из чужой схемы), studio уведомляется; разбор вручную — автосамолечения нет (риск маскировки утечки)
Джоба сноса/провижининга завислаHorizon-очередь tenant-{id} (или multitenancy) — отставание/failedштатный retry Horizon; статус failed/suspended в списке подсказывает шаг остановки («UX-требования»)

Бэкап/рестор (per-schema): в бэкап тенанта попадает дамп его PG-схемы целиком (все таблицы модулей, работающих в ней) — независимо от остальных тенантов и от public-схемы. После рестора пересоздаётся: реестр cms_tenants/cms_tenant_domains — из бэкапа public-схемы (отдельного от тенантских дампов); денормализованные агрегаты/кеши в восстановленной схеме — командами модулей, работающих там (cms:<slug>:recount/reindex); рестор без них не завершён.

Производительность и кеш

  • Ожидаемые объёмы. Режим «одна инсталляция студии — много клиентов с изолированными данными»: реалистично десятки, у крупной студии — низкие сотни тенантов (max_tenants — явная квота); за пределами нескольких сотен схем в одной PostgreSQL-базе эксплуатационные издержки (backup/vacuum/каталог схем) требуют нескольких инстансов БД — вне рамок ТЗ. Данных на тенанта — единицы МБ … несколько ГБ (сайт-визитка/каталог).
  • Горячий путь — резолвинг домена → tenant_id → переключение схемы БД. Самый критичный по производительности путь всей CMS (порядок зафиксирован контрактом ядра, п.6 ревизии контрактов 14.07.2026): выполняется на каждый запрос раньше RequestContext ядра и раньше page-cache lookup (сайт/локаль/город резолвятся уже внутри схемы тенанта — его собственный RequestContext), ключ page-cache всегда включает tenant/site/locale/city. Бюджет: 0 запросов к Postgres при попадании в кеш реестра доменов (Redis, тег multitenancy.tenants, реестр целиком — маленький даже при сотнях тенантов) — только словарь domain → schema_name и переключение search_path/соединения; 1 запрос к cms_tenant_domains допустим лишь на холодном старте (первый запрос после инвалидации), результат немедленно кладётся обратно в кеш.
  • Критичные индексы: уникальный индекс на domain в cms_tenant_domains и в cms_tenants (ловит гонки провижининга — см. «Крайние случаи»); индекс на tenant_id (FK) в cms_tenant_domains — обратный поиск всех доменов тенанта.
  • Что кешируется: реестр доменов (multitenancy.tenants, полный дамп domain → {tenant_id, schema_name, status}); настройки группы multitenancy — по стандарту §6, 0 запросов на горячем пути.
  • Инвалидация: событиями TenantProvisioned, TenantSuspended, TenantDeleted — точечная замена одной записи в кешированном реестре, не полный сброс тега на каждое изменение (реестр целиком перечитывается только при отсутствии кеша).
  • Изоляция кеша между тенантами — два уровня: (1) реестр доменов — общий кеш ядра инсталляции; (2) кеш и очереди внутри схемы тенанта — Redis-префикс tenant:{id}: на все теги модулей, работающих в его схеме, и отдельные Horizon-очереди tenant-{id} вместо общих <slug> (queue_isolation) — иначе совпадающие числовые id в разных схемах (reviews:product:42 тенантов A и B) пересеклись бы в одном Redis. Отдельная Redis DB на тенанта при префиксации избыточна.
  • Connection pool: соединение с БД не переиспользуется между запросами разных тенантов без явного сброса search_path — детали в «Безопасность» и «Крайние случаи».
  • Учёт квот тенанта (tenant_quota_disk_mb/tenant_quota_pages/tenant_quota_users) — использование считает джоба агрегации по расписанию (ScheduleRegistrar), результат в кеше per-tenant; операции записи не делают синхронный COUNT/SUM по схеме на горячем пути — свежесть ограничена интервалом джобы, что приемлемо (не финансовый лимит с точностью до операции).
  • Бэкап per-schema: дамп конкретной PG-схемы тенанта снимается отдельно от остальных (нужно для require_backup_before_delete и выборочного восстановления одного клиента); что в дампе и что пересоздаётся после рестора — «Фоновая работа» (ранбук §15).

Безопасность

Входные границы: middleware резолвинга тенанта по домену (критичная точка запроса — см. «Производительность и кеш»), Filament (CRUD тенантов — только studio-роль), команды (provision/migrate/doctor — доступ только из доверенного контекста деплоя, не из веб-запроса). Снос тенанта обязан требовать успешный предварительный бэкап — операция необратима без него.

Векторы атак, специфичные для модуля:

  • подмена Host-заголовка для попадания в чужую схему — резолвинг домена идёт строго точным совпадением строки в cms_tenant_domains (без wildcard/regex, без частичного совпадения поддомена); домен, не найденный в реестре, — 404/503 («Крайние случаи»), а не резолв в дефолтную/первую схему;
  • SQL injection в имя схемы при провижинингеschema_name не собирается конкатенацией из пользовательского ввода (domain): генерируется модулем как {schema_prefix}{id} (числовой id, не строка домена), SET search_path вызывается только с именем схемы, прошедшим внутреннюю валидацию, никогда с сырым вводом формы/API;
  • утечка через общий connection pool между запросами разных тенантов (pgbouncer/persistent connections) — search_path устанавливается заново на каждый запрос явно в middleware; переиспользование соединения без сброса схемы — запрещённый паттерн, ловится canary-запросом cms:multitenancy:doctor (сверка фактической схемы с ожидаемой); при pgbouncer — режим session (не transaction) для соединений со search_path, либо явный RESET search_path перед возвратом в пул;
  • ПДн тенантов — модуль сам хранит только domain/schema_name организации; ПДн конечных пользователей живут внутри схемы тенанта под ретеншн-политикой работающих там модулей; снос схемы по запросу тенанта закрывает требование 152-ФЗ «забыть по запросу» разом на уровне всей схемы;
  • «шумный сосед» — один тенант не должен исчерпывать общие ресурсы БД/очередей за счёт остальных: (1) queue_isolation — отдельные Horizon-очереди tenant-{id} вместо общих <slug>, тяжёлая фоновая нагрузка одного тенанта не блокирует джобы остальных; (2) tenant_db_query_rate_limit — лимит запросов/сек к БД на схему тенанта на уровне pgbouncer/лимитера соединений (не в коде модуля), превышение — троттлинг/отказ новых соединений этой схеме, а не деградация БД для всей установки.

Права: multitenancy.view, multitenancy.manage — только studio-роль; матрица ролей: studio × (просмотр списка, провижининг, снос, миграция) — прочие роли (админ/менеджер/ редактор тенанта) работают внутри схемы своего тенанта и не имеют доступа к панели мультитенантности.

UX-требования

Единственная аудитория модуля — studio-роль (владелец инсталляции, не клиент); операции редкие, но необратимые/высокорисковые.

  • пустой список тенантов — подсказка «тенантов пока нет» с кнопкой «добавить первый тенант», не голая пустая таблица;
  • статус провижининга — провижининг не мгновенный (схема + миграции + дефолтные данные): список показывает статус provisioning с индикатором прогресса (поллинг или cms/realtime, если включён), не «зависшую» строку; провал показывает шаг, на котором упало (создание схемы / миграции / дефолтные данные);
  • снос тенанта — двойное подтверждение — модальное окно требует ввода домена текстом (не просто «ОК») + чекбокс «бэкап выполнен и проверен»; кнопка «снести» неактивна, пока require_backup_before_delete=true и бэкап не подтверждён системой, а не только визуально пользователем;
  • попытка выключить модуль с активными тенантами — понятная ошибка («модуль нельзя выключить: N активных тенантов; сначала мигрируйте данные в общую схему или снесите тенантов») со ссылкой на процедуру, не generic 500/тихий отказ (§3 стандарта);
  • массовые действия — «приостановить» доступно массово по выборке, «снести» — только по одному тенанту (необратимость исключает batch-удаление без индивидуального подтверждения).

Крайние случаи и типовые баги

Изоляция данных тенантов — главный инвариант модуля; большинство сценариев ниже — про её защиту на границах, а не про функциональные удобства.

  • запрос ошибочно резолвится не в ту схему (баг в middleware) → ловится contract-тестом (домен A никогда не видит данные, созданные под доменом B) и health-чеком cms:multitenancy:doctor: canary-запрос на известный тестовый домен каждого тенанта сверяет фактический search_path подключения с ожидаемой схемой; расхождение — критичный алерт cms/health, не тихий баг в проде;
  • тенант удалён (схема снесена) посреди активного HTTP-запроса его пользователя → снос двухфазный: suspended → дожитие активных запросов/соединений deletion_grace_period_minutes («Настройки») → физический снос джобой; запрос, стартовавший до грейс-периода, либо успевает завершиться на открытом соединении, либо после сноса получает ошибку подключения и отдаёт 503 с человеческим текстом «сайт временно недоступен», не 500 со стектрейсом БД;
  • cms:multitenancy:migrate по всем тенантам — частичный провал (50 из 100) → стратегия студии: не откат всех, а продолжение остальных схем с итоговым отчётом (--json: список успешных/упавших schema_name) — согласуется с §9 стандарта (Bus::batch чанками, прогресс, allowFailures); повторный прогон применяется только к упавшим схемам (идемпотентность миграций);
  • гонка — два параллельных запроса на провижининг одного домена → уникальный индекс на domain в cms_tenant_domains/cms_tenants ловит конфликт на вставке; job второго запроса завершается идемпотентно («тенант с этим доменом уже существует/провижинится»), вторая схема не создаётся;
  • провижининг падает на середине (схема создана, миграции не прошли) → джоба не сносит частичную схему автоматически (риск потерять применённые миграции при флаки-сбое); тенант остаётся в failed с деталью шага; повторный cms:multitenancy:provision идемпотентно доводит миграции до конца; ручной снос недомигрированной схемы — отдельное явное действие studio, не автоматика;
  • middleware резолвинга сам упал до подключения к БД (ошибка разбора Host или недоступен Redis-кеш реестра доменов) → fail-closed: запрос получает 503, соединение к БД не открывается вовсе — деградация никогда не означает «подключиться к схеме по умолчанию/первого тенанта»; то же правило, что и подмена Host в разделе «Безопасность»;
  • джоба очереди тенанта выполняется уже после его удаления → идемпотентность: каждая джоба модулей внутри схемы тенанта проверяет существование tenant_id/схемы перед выполнением (через реестр модуля, не напрямую); тенант отсутствует/suspended — джоба завершается no-op с логом, не роняет очередь исключением;
  • suspended-тенант — запросы блокируются → 503 Service Unavailable (не 404 — домен существует технически, просто временно недоступен) с телом, объясняющим приостановку; studio видит причину и дату приостановки в списке тенантов;
  • тенант достиг квоты (диск/страницы/пользователи) → операция, пробивающая лимит (tenant_quota_*), отклоняется понятной ошибкой внутри схемы тенанта (422, не 500); чтение и созданное продолжают отдаваться, ограничивается только новое; проверка — по последнему значению джобы агрегации («Производительность и кеш»), не синхронным пересчётом — кратковременное превышение между циклами допустимо;
  • пустая установка без единого тенантаcms:multitenancy:migrate/doctor отрабатывают штатно на пустом списке схем (0 итераций, код 0, не ошибка «нет данных»); Filament показывает пустое состояние («UX-требования»);
  • общий connection pool между запросами разных тенантов (pgbouncer/persistent connections) → search_path устанавливается заново на каждый запрос явно в middleware резолвинга; переиспользование соединения без сброса схемы — запрещённый паттерн, ловится тем же canary-запросом doctor'а, что и первый сценарий;
  • место мультитенантности в жизненном цикле запроса ядра — резолвинг домена → tenant_id есть первый шаг конвейера, раньше page-cache lookup: зафиксировано контрактом ядра, п.6 ревизии контрактов 14.07.2026, а не решением этого модуля. Контракт multitenancy регистрирует middleware с наивысшим приоритетом в HTTP-конвейере ядра; попадание в чужой кеш исключено конструктивно порядком резолва, не дисциплиной модуля.

Донорский код

Донор: — (новая разработка; архитектурный референс — пакет stancl/tenancy).

Миграция legacy (§16 стандарта): cms:multitenancy:import-legacy не применим — донора с боевыми данными нет (stancl/tenancy — архитектурный образец, не источник данных). Отдельный кейс — перевод работающего на общей схеме сайта в изолированную схему тенанта (клиент вырастает из cms/multisite в cms/multitenancy): это не импорт с донорской платформы, а перенос данных внутри одной инсталляции CMS v2 (общая схема → новая tenant_{id}); отдельная процедура, вне рамок этого ТЗ.

Тесты и приёмка

  • [ ] Контрактные тесты: запрос к домену тенанта не видит данные другого тенанта (изоляция схемы);
  • [ ] health-чек модуля проверяет соответствие версии миграций во всех схемах тенантов;
  • [ ] попытка одновременного включения с cms/multisite без явного режима блокируется с понятной ошибкой;
  • [ ] снос тенанта невозможен без предварительного успешного бэкапа;
  • [ ] очереди и кеш не текут между тенантами (префиксы tenant:{id}:, отдельные Horizon-очереди);
  • [ ] cms:multitenancy:migrate --json идемпотентна и безопасна при повторном запуске;
  • [ ] cms:multitenancy:migrate --json при частичном провале продолжает остальные схемы и возвращает отчёт по упавшим, не откатывает успешные;
  • [ ] canary-запрос doctor'а обнаруживает расхождение search_path с ожидаемой схемой;
  • [ ] подмена Host-заголовка на несуществующий/чужой домен не резолвится ни в чью схему (404/503, не дефолтная схема);
  • [ ] гонка параллельного провижининга одного домена не создаёт двух схем (уникальный индекс + идемпотентность job);
  • [ ] запрос к suspended-тенанту получает 503 с понятным телом, не 404/500;
  • [ ] джоба очереди для удалённого/приостановленного тенанта завершается no-op, не роняет очередь;
  • [ ] соединение к БД не переиспользуется между запросами разных тенантов без явного сброса search_path;
  • [ ] middleware резолвинга тенанта — наивысший приоритет: раньше page-cache lookup и RequestContext ядра, ключ page-cache включает tenant/site/locale/city (п.6 ревизии 14.07.2026);
  • [ ] достижение квоты тенанта (диск/страницы/пользователи) — понятная ошибка 422 внутри его схемы, не 500; чтение и уже созданные данные продолжают отдаваться;
  • [ ] бэкап per-schema: снятие/восстановление дампа одной схемы не затрагивает остальных; после рестора реестр и денормализованные агрегаты восстанавливаются по ранбуку §15;
  • [ ] tenant_db_query_rate_limit — «шумный сосед», исчерпывающий пул соединений/запросы к БД, не деградирует ответ других тенантов той же установки;
  • [ ] пустая установка без тенантов — doctor/migrate завершаются кодом 0, не ошибкой;
  • [ ] контрактный набор cms-testing зелёный, пакет протестирован в testbench-изоляции;
  • [ ] feature-тест на каждый роут API; тестовая БД только multitenancy_test, migrate:fresh/refresh/reset запрещены.

Внутренняя база знаний студии