Самый дорогой баг в мультиарендном приложении выглядит безобидно. Кто-то добавил быстрый метод в репозиторий, забыл про фильтр по организации, и в списке задач одной компании появилась задача другой. Тесты зелёные: в тестовой базе организация одна.
Я живу с этим риском в двух проектах и постепенно свёл его к нескольким правилам. Ни одно из них не про «быть внимательнее».
Три способа изолировать арендаторов
База на арендатора. Полная изоляция, невозможно перепутать физически. Цена — миграции нужно прогнать на всех базах, соединений столько же, сколько клиентов, а любой сводный отчёт превращается в отдельную задачу. Для сотни клиентов это уже инфраструктурный проект.
Схема на арендатора в одной базе. Компромисс: одно соединение, SET search_path на запрос. Миграции всё равно множатся, а Doctrine к такому режиму относится без энтузиазма — метаданные кешируются на приложение, а не на схему.
Общая таблица с колонкой организации. Одна схема, одни миграции, любые сводные запросы. Вся изоляция — на приложении.
Я выбрал третье и считаю выбор правильным для продукта, где клиенты — это команды по 5–200 человек, а не банки с требованием физического разделения. Плата за выбор конкретная, и её нужно назвать вслух: любая ошибка в фильтрации становится утечкой данных между компаниями.
Организация в маршруте
Публичные маршруты выглядят так:
/org/{orgKey}/projects
/org/{orgKey}/issues/{id}
/org/{orgKey}/docs/spaces/{spaceKey}orgKey — это короткий человекочитаемый ключ вроде acme, а не UUID. Он виден в URL, а текущую организацию не нужно хранить в сессии. Это важнее, чем кажется: подрядчик, который состоит в четырёх организациях, открывает четыре вкладки и в каждой сразу видит контекст.
Резолвер достаёт организацию и проверяет членство. Ниже сокращённый фрагмент без объявления зависимостей:
final class CurrentOrganizationResolver
{
public function resolveByKey(string $orgKey): Organization
{
$organization = $this->organizations->findOneByKey($orgKey);
if (!$organization) {
throw new NotFoundHttpException();
}
$member = $this->members->findActive($organization, $this->security->getUser());
if (!$member) {
// 404, а не 403: существование чужой организации — тоже информация
throw new NotFoundHttpException();
}
$this->context->set($organization, $member);
return $organization;
}
}Ответ 404 вместо 403 — сознательное решение. Одинаковый статус для отсутствующей и недоступной организации не доказывает, что ключ существует, и уменьшает утечку информации при переборе. Абсолютной защиты от перечисления это не даёт: различия во времени ответа, публичные ссылки и другие каналы всё равно нужно проверять отдельно.
Альтернатива с поддоменами (acme.app.example) выглядит солиднее и стоит дороже: сертификат с подстановочным знаком, DNS, отдельная возня в локальной среде. Я оставил её на потом и пока не жалею.
Где ставить фильтр
Здесь я перебрал три варианта.
В контроллере. Каждый метод сам подставляет организацию в запрос. Прозрачно, видно глазами и абсолютно ненадёжно: достаточно одного нового метода, написанного в пятницу.
В репозитории. Каждый метод репозитория принимает организацию явным аргументом. Уже лучше — забыть сложнее, потому что аргумент обязателен и статический анализ ругается. Именно так у меня написано большинство выборок:
public function findForBoard(Organization $org, Uuid $projectId): array
{
return $this->createQueryBuilder('i')
->andWhere('i.organization = :org')->setParameter('org', $org)
->andWhere('i.project = :project')->setParameter('project', $projectId)
->getQuery()->getResult();
}Фильтр Doctrine. Глобальное условие, которое подмешивается в SQL, сгенерированный ORM для отмеченной сущности. В обычном репозитории или Query Builder его уже нельзя забыть случайно; нативный SQL остаётся отдельным обходным путём.
final class OrganizationFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $target, $alias): string
{
if (!$target->reflClass->implementsInterface(OrganizationOwned::class)) {
return '';
}
return sprintf('%s.organization_id = %s', $alias, $this->getParameter('org_id'));
}
}Я использую фильтр как страховку, а явные условия — как основной механизм. Дублирование намеренное: фильтр ловит то, что забыли, явное условие делает запрос читаемым и работает даже там, где фильтр отключён.
Иногда фильтр приходится отключать: например, для консольных команд, которые обходят все организации. К нативным SQL/DBAL-запросам он вовсе не применяется. При lazy loading Doctrine тоже добавляет фильтр; неприятность там другая: если to-one указывает на скрытую строку, инициализация прокси может закончиться EntityNotFoundException. Поведение eager-связей менялось между версиями Doctrine, поэтому его нужно закрепить интеграционным тестом на установленной версии. Все обходы фильтра надо знать поимённо.
Voters вместо проверки ролей
Роль ROLE_ADMIN в мультиарендном приложении почти бессмысленна: администратор чего? Своей организации или всей системы?
У меня роль хранится в членстве, а не в пользователе. Проверка идёт через voter, который получает и объект, и текущее членство:
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$member = $this->context->member();
if (
!$member
|| !$subject->getOrganization()->getId()->equals(
$member->getOrganization()->getId(),
)
) {
return false;
}
return match ($attribute) {
'ISSUE_EDIT' => $member->isAtLeast(MemberRole::Member),
'ISSUE_DELETE' => $member->isAtLeast(MemberRole::Admin),
default => false,
};
}Первое условие в этом методе важнее второго. Оно проверяет не право, а принадлежность, и срабатывает раньше любой ролевой логики.
Тесты, которые ловят утечку
На уровне приложения обязательна проверка, которая специально пытается залезть в чужое. PostgreSQL Row-Level Security дал бы ещё одну, более жёсткую границу на уровне базы, но это отдельная архитектура с политиками, ролями и явным контекстом соединения. Описанная здесь схема на RLS не опирается: тесты страхуют прикладные фильтры, но не создают отдельной границы на уровне базы.
Я держу базовый класс, от которого наследуются функциональные тесты контроллеров. В нём создаются две организации с полным набором данных, и есть один метод:
public function testForeignOrganizationGetsNotFound(): void
{
$this->loginAs($this->alice); // участник организации A
$this->client->request('GET', "/org/{$this->orgB->getKey()}/issues/{$this->issueB->getId()}");
self::assertResponseStatusCodeSame(404);
}Правило: для каждого нового публичного маршрута в набор данных теста добавляется отдельный случай. Не «хорошо бы покрыть», а обязательная часть маршрута. Это дало три реальные находки за год, и все три были в местах, которые я писал «на минуту».
Второй тест смотрит на выборки: создаю по десять задач в двух организациях и проверяю, что список возвращает ровно десять. Такой тест ловит забытый фильтр там, где нет отдельного маршрута.
Фоновые задачи — отдельная история
У обработчика сообщения нет ни запроса, ни пользователя, ни текущей организации. Значит, фильтр Doctrine не настроен, и вся защита, построенная на контексте, не работает.
Я решил это тем, что каждое сообщение обязано нести идентификатор организации:
final readonly class RecalculateBoardCounters
{
public function __construct(
public string $organizationId,
public string $projectId,
) {}
}Обработчик загружает организацию по organizationId, устанавливает контекст и включает фильтр до первого обращения к прикладным репозиториям. Сам идентификатор не считается доказательством доступа, а публиковать такое сообщение должен только доверенный код приложения. Выглядит как ритуал, но альтернатива — обработчик, который однажды пересчитает счётчики по всем организациям сразу.
Отдельно про уведомления: письмо, собранное в фоне, легко получает ссылку без orgKey или с ключом другой организации. Ссылки в письмах я собираю через тот же помощник, что и в шаблонах, и он требует организацию аргументом.
Что бы я поменял
Ввёл бы фильтр Doctrine с первого дня, а не через полгода. Ретроспективно добавлять глобальное условие в приложение, где часть запросов уже написана нативным SQL, — занятие на неделю с постоянным вопросом «а этот запрос точно должен видеть всё?».
И завёл бы отдельную роль системного администратора вне организаций сразу. Сейчас служебные операции идут через консольные команды, потому что веб-интерфейс для «посмотреть чужую организацию» я так и не сделал — а поддержке он нужен.