Тема
ТЗ — Мультитенантность (cms/multitenancy)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★ · Донор: — (референс stancl/tenancy) Статус: ТЗ к разработке
Назначение и возможности
Изоляция данных по тенанту на уровне БД (отдельная схема PostgreSQL или префикс таблиц) — в отличие от cms/multisite, где данные общие и разделены только измерением site_id. Используется, когда сайты принадлежат разным клиентам и данные не должны пересекаться даже теоретически. Референс — модель stancl/tenancy.
- Провижининг нового тенанта: схема БД, миграции, дефолтные данные;
- резолвинг тенанта по домену на раннем этапе запроса (до подключения к БД);
- переключение соединения БД на схему тенанта (
tenancy()->initialize()); - изоляция очередей/кеша по тенанту (префиксы ключей, отдельные Horizon-очереди);
- удаление тенанта (снос схемы) с обязательным предварительным бэкапом;
- список тенантов и их статус (активен/приостановлен) в центральной админке.
Зависимости и выключение
requires: — · provides: multitenancy · conflicts: cms/multisite — совместное включение требует явного выбора режима изоляции (документированное решение, не автоматический выбор ядра); по умолчанию оба модуля вместе не активируются.
При выключении модуль не может быть отключён на установке с уже провизионированными тенантами без миграции данных обратно в общую схему — деградация здесь равносильна блокировке отключения до ручной миграции (health-чек предупреждает заранее).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_tenants | id, domain, schema_name, status, provisioned_at | Центральная таблица (в public-схеме) |
cms_tenant_domains | tenant_id, domain | Домены/поддомены тенанта |
Данные самого тенанта живут в отдельной PG-схеме (tenant_{id}) со своей копией таблиц ядра — вне зоны видимости этого файла (создаются миграциями ядра при провижининге). status — enum → PHP Enum (active/suspended); tenant_id в cms_tenant_domains — constrained('cms_tenants')->index(); domain — уникальный индекс в обеих таблицах.
Входные и выходные данные
Входы:
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Filament-форма провижининга (studio) | domain | FormRequest: required|string|max:255, уникален в cms_tenants/cms_tenant_domains, тот же валидатор домена, что и у API |
API POST /tenants | domain | FormRequest + ability multitenancy.manage; провижининг ставится в очередь, не выполняется синхронно |
API DELETE /tenants/{id} | id, подтверждение бэкапа | FormRequest + require_backup_before_delete: без успешного предварительного бэкапа запрос отклоняется 422 |
Команда cms:multitenancy:provision {domain} --json | domain (CLI-аргумент) | тот же сервис-валидатор домена, что у формы/API (общий код, не дублирование правил) |
| Входящий HTTP-запрос на публичный домен | заголовок Host | middleware резолвинга: точное совпадение строки в cms_tenant_domains, без wildcard/regex по Host |
Всё, что не входит в этот список (произвольные query-параметры, поля вне domain/подтверждения бэкапа), отвергается на уровне FormRequest — whitelist-принцип §11 стандарта.
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Ядро — подключение к БД | резолвленные tenant_id/schema_name | provides-контракт multitenancy (канал 3), внутренний вызов, не HTTP |
API GET /tenants | id, domain, status, provisioned_at | конверт {data, meta}, keyset-пагинация |
| Filament (список тенантов) | id, domain, status, provisioned_at | таблица с фильтром по статусу |
cms:multitenancy:provision --json | tenant_id, schema_name, status | JSON |
События TenantProvisioned/TenantSuspended/TenantDeleted | см. «События и обмен» | внутренняя шина, канал 1 |
Настройки (группа multitenancy)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
multitenancy.schema_prefix | string | "tenant_" | — | Префикс имени схемы при провижининге |
multitenancy.auto_provision_on_signup | bool | false | — | Автосоздание тенанта при регистрации |
multitenancy.queue_isolation | bool | true | — | Отдельные очереди Horizon на тенанта |
multitenancy.max_tenants | int | 100 | — | Квота инсталляции: максимум тенантов на установку (0 = без лимита) |
multitenancy.tenant_quota_disk_mb | int | 5000 | — | Квота тенанта: диск на схему, МБ (0 = без лимита; не путать с max_tenants — той квотируется число тенантов, этой — ресурс каждого) |
multitenancy.tenant_quota_pages | int | 10000 | — | Квота тенанта: максимум страниц внутри его схемы |
multitenancy.tenant_quota_users | int | 50 | — | Квота тенанта: максимум пользователей внутри его схемы |
multitenancy.deletion_grace_period_minutes | int | 15 | — | Дожитие активных запросов/соединений тенанта перед физическим сносом схемы |
multitenancy.require_backup_before_delete | bool | true | — | Запрещать снос схемы без успешного предварительного бэкапа (kill-switch только для dev-инсталляций) |
multitenancy.tenant_registry_cache_ttl | int | 0 | — | TTL кеша реестра доменов, сек. (0 = кеш живёт до инвалидации событием — рекомендуемый режим) |
multitenancy.tenant_db_query_rate_limit | int | 200 | — | Лимит запросов/сек к БД на схему тенанта (pgbouncer/лимитер соединений) — защита от «шумного соседа» (0 = без лимита, не рекомендуется на многоарендных установках) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/multitenancy/tenants | studio-роль | Список тенантов установки (keyset-пагинация) |
| POST | /api/v1/admin/multitenancy/tenants | studio-роль | Провижининг нового тенанта (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-контракт multitenancy | out | ядро резолвит tenant_id по домену и переключает схему до инициализации остальных сервисов и до RequestContext |
| Ядро — миграции | сервис-вызов (внутренний, часть провижининга) | out | провижининг прогоняет миграции ядра и модулей в новой схеме тенанта |
cms/multisite | conflicts (манифест, не рантайм-канал) | — | взаимоисключение фиксируется декларативно; проверка — на этапе enable, не событием |
Очередь multitenancy | очередь | out | провижининг и снос тенанта выполняются джобами, не синхронно из HTTP/команды |
cms/health | health-чек из манифеста | 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запрещены.