Прямой ответ
В мультитенантном приложении с общей схемой каждый запрос к данным должен содержать фильтр по идентификатору клиента. Утечка происходит не из-за взлома, а из-за пропущенного условия в одном из десятков запросов, поэтому защита должна быть не только в коде.
Источники утечек, встречающиеся чаще всего:
| Источник | Признак |
|---|---|
| Запрос без фильтра | Возвращает строки всех тенантов |
| Соединение таблиц без условия | Данные присоединяются по ключу, игнорируя тенанта |
| Кеш без тенанта в ключе | Один клиент читает данные другого |
| Фоновая задача без контекста | Обработка идёт по всем клиентам |
| Административный интерфейс | Доступ без проверки прав на тенанта |
Первичная мера — установка контекста из аутентифицированного запроса, а не из параметра, который передаёт клиент. Идентификатор тенанта берётся из токена, поскольку значение из тела запроса подделывается:
# tenant_id — из аутентификации, а не из запроса
def get_tenant_id(user: AuthenticatedUser) -> uuid.UUID:
return user.tenant_id
Вторичная мера — защита на уровне базы через row-level security:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::uuid);
При активном соединении задаётся значение:
SET LOCAL app.tenant_id = '2f1c...';
После этого запрос без фильтра вернёт пустой результат вместо чужих данных. Ошибка остаётся ошибкой, но не становится утечкой.
Третья мера — тесты, которые проверяют недоступность данных другого клиента:
def test_order_nevidim_chuzhomu_tenantu(client_tenant_a, db, order_of_b):
r = client_tenant_a.get(f"/api/orders/{order_of_b.id}")
assert r.status_code == 404
Практика: устранение источников утечек
1. Берите тенанта из аутентификации. Значение из параметра запроса или тела контролируется клиентом; о механизмах — в статье про зависимости FastAPI.
2. Добавьте условие в соединения таблиц. Присоединение по ключу без проверки тенанта возвращает связанные строки из других клиентов, даже если основной запрос отфильтрован.
3. Включайте тенанта в ключ кеша. Ключ вида orders:42 вместо orders:tenant7:42 — прямой путь к утечке.
4. Передавайте контекст в фоновые задачи. Задача, запущенная по расписанию, не имеет HTTP-запроса и требует явного указания клиента либо перечисления всех с корректной фильтрацией.
5. Ограничивайте административный доступ. Интерфейс поддержки должен явно указывать, под каким тенантом выполняется действие, и фиксировать это в журнале; о журнале — в статье про audit log.
6. Проверяйте запросы на уровне ORM. Глобальная фильтрация по тенанту в ORM снижает риск пропуска, но не заменяет row-level security; о запросах — в статье про SQLAlchemy.
7. Покрывайте тестами отрицательные случаи. Тест на доступ к чужим данным обязательнее теста на доступ к своим; о безопасности — в статье про безопасность БД.
8. Проверяйте выгрузки и отчёты. Экспорт данных обычно реализуется отдельным кодом, который строит запросы напрямую, минуя слой доступа. Отсутствие фильтра в выгрузке означает передачу данных всех клиентов в одном файле.
9. Пересматривайте запросы при рефакторинге. Перенос логики между модулями часто сопровождается переписыванием запросов, и условие фильтрации теряется. Проверка наличия фильтра входит в чек-лист ревью наравне с тестами.
10. Фиксируйте контекст в обработчиках очередей. Задача, поставленная в очередь одним клиентом, должна нести его идентификатор в полезной нагрузке, а не полагаться на состояние процесса; о гарантиях доставки — в статье про очереди.
11. Проверяйте выгрузки данных при смене тенанта. Поддержка и администраторы переключаются между клиентами, и остаточный контекст предыдущего тенанта приводит к показу чужих данных. Контекст должен устанавливаться на каждый запрос, а не храниться в сессии процесса.
Рекомендации:
- Считайте row-level security обязательным слоем. Код меняется часто, а политика в базе защищает независимо от его корректности.
- Запретите запросы без фильтра статическим анализом. Правило линтера для моделей с
tenant_idловит пропуск до ревью. - Проверяйте кеш на утечки отдельно. Тесты запросов кеш не покрывают, а он хранит данные дольше.
- Фиксируйте контекст в логах. Идентификатор тенанта в структурированном логе ускоряет расследование инцидента.
- Пересматривайте новые запросы на ревью. Проверка наличия фильтра — отдельный пункт ревью для мультитенантных моделей.
Практический сценарий: данные одного клиента в ответах другому из-за кеша
Контекст: после включения кеширования списков заказов два клиента сообщили о чужих заказах в интерфейсе.
- Диагностика. В запросах фильтрация была корректной, а ключ кеша формировался как
orders:{user_id}:{page}. Пользователи разных тенантов получали одинаковый ключ при совпадении страницы, и второй читал данные первого. - Причина. Кеш вводился как оптимизация и писался до слоя фильтрации по тенанту, поэтому контекст клиента в ключ не попал.
- Действия. (а) Ключ изменили на
orders:{tenant_id}:{user_id}:{page}; (б) добавили тест, который читает кеш под одним тенантом и ожидает промах под другим; (в) включили row-level security, поскольку инцидент показал, что одного слоя защиты недостаточно; (г) проверили остальные ключи кеша и нашли ещё два без тенанта; (д) добавили правило ревью: любой ключ кеша содержит идентификатор тенанта. - Результат. Утечка устранена, а повторный анализ показал, что риск был системным: три ключа из одиннадцати формировались без контекста клиента.
Вывод: фильтрация в запросах не покрывает кеш, фоновые задачи и соединения таблиц. Утечка между клиентами чаще всего возникает в слоях, добавленных позже основного кода, а защита на уровне базы ограничивает последствия пропуска.
FAQ
Достаточно ли фильтрации в ORM? Нет. ORM защищает от пропуска в типичных запросах, но сырой SQL, соединения таблиц и кеш остаются вне его контроля.
Откуда брать идентификатор тенанта? Из аутентифицированного контекста. Значение, переданное клиентом, проверяется на соответствие правам, а не используется напрямую.
Как тестировать отсутствие утечек? Тестами на доступ к данным другого тенанта с ожиданием ошибки, а также проверкой ключей кеша и результатов фоновых задач.
Что делать, если часть таблиц не содержит tenant_id? Справочники, общие для всех клиентов, не требуют фильтрации, но должны быть явно помечены как общие, чтобы отсутствие условия не выглядело ошибкой.
Источники: PostgreSQL docs (row security), SQLAlchemy docs (query guide), OWASP Top 10 — ссылки в шапке.