almdev Технический блог

Мультиарендность без отдельной базы на клиента
Одна схема, много организаций и один забытый WHERE, который стоит дороже всего.

Как устроена изоляция арендаторов в общей базе: org-scoped маршруты, выбор места для фильтра, voters вместо проверки ролей и тесты, которые ловят утечку данных.

Подкатегория: Архитектура

Чтение
5 мин
Технологии / версии
Symfony · Doctrine · Multitenancy · Security
17 мар 2026 · 5 мин · 3 просмотра · Backend
Отдельные ключи направляют запросы по изолированным маршрутам организаций
Архитектура 03/2026

Самый дорогой баг в мультиарендном приложении выглядит безобидно. Кто-то добавил быстрый метод в репозиторий, забыл про фильтр по организации, и в списке задач одной компании появилась задача другой. Тесты зелёные: в тестовой базе организация одна.

Я живу с этим риском в двух проектах и постепенно свёл его к нескольким правилам. Ни одно из них не про «быть внимательнее».

Три способа изолировать арендаторов

База на арендатора. Полная изоляция, невозможно перепутать физически. Цена — миграции нужно прогнать на всех базах, соединений столько же, сколько клиентов, а любой сводный отчёт превращается в отдельную задачу. Для сотни клиентов это уже инфраструктурный проект.

Схема на арендатора в одной базе. Компромисс: одно соединение, 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, — занятие на неделю с постоянным вопросом «а этот запрос точно должен видеть всё?».

И завёл бы отдельную роль системного администратора вне организаций сразу. Сейчас служебные операции идут через консольные команды, потому что веб-интерфейс для «посмотреть чужую организацию» я так и не сделал — а поддержке он нужен.

#symfony #doctrine #multitenancy #security