Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Tenant ID: фильтрация запросов и утечки между клиентами

Tenant ID: как избежать утечки данных между клиентами

Tenant ID в каждом запросе: типичные источники утечек между клиентами, контекст тенанта в фоновых задачах, ключи кеша и защита на уровне базы.

Прямой ответ

В мультитенантном приложении с общей схемой каждый запрос к данным должен содержать фильтр по идентификатору клиента. Утечка происходит не из-за взлома, а из-за пропущенного условия в одном из десятков запросов, поэтому защита должна быть не только в коде.

Источники утечек, встречающиеся чаще всего:

Источник Признак
Запрос без фильтра Возвращает строки всех тенантов
Соединение таблиц без условия Данные присоединяются по ключу, игнорируя тенанта
Кеш без тенанта в ключе Один клиент читает данные другого
Фоновая задача без контекста Обработка идёт по всем клиентам
Административный интерфейс Доступ без проверки прав на тенанта

Первичная мера — установка контекста из аутентифицированного запроса, а не из параметра, который передаёт клиент. Идентификатор тенанта берётся из токена, поскольку значение из тела запроса подделывается:

# 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. Проверяйте выгрузки данных при смене тенанта. Поддержка и администраторы переключаются между клиентами, и остаточный контекст предыдущего тенанта приводит к показу чужих данных. Контекст должен устанавливаться на каждый запрос, а не храниться в сессии процесса.

Рекомендации:

  1. Считайте row-level security обязательным слоем. Код меняется часто, а политика в базе защищает независимо от его корректности.
  2. Запретите запросы без фильтра статическим анализом. Правило линтера для моделей с tenant_id ловит пропуск до ревью.
  3. Проверяйте кеш на утечки отдельно. Тесты запросов кеш не покрывают, а он хранит данные дольше.
  4. Фиксируйте контекст в логах. Идентификатор тенанта в структурированном логе ускоряет расследование инцидента.
  5. Пересматривайте новые запросы на ревью. Проверка наличия фильтра — отдельный пункт ревью для мультитенантных моделей.

Практический сценарий: данные одного клиента в ответах другому из-за кеша

Контекст: после включения кеширования списков заказов два клиента сообщили о чужих заказах в интерфейсе.

  1. Диагностика. В запросах фильтрация была корректной, а ключ кеша формировался как orders:{user_id}:{page}. Пользователи разных тенантов получали одинаковый ключ при совпадении страницы, и второй читал данные первого.
  2. Причина. Кеш вводился как оптимизация и писался до слоя фильтрации по тенанту, поэтому контекст клиента в ключ не попал.
  3. Действия. (а) Ключ изменили на orders:{tenant_id}:{user_id}:{page}; (б) добавили тест, который читает кеш под одним тенантом и ожидает промах под другим; (в) включили row-level security, поскольку инцидент показал, что одного слоя защиты недостаточно; (г) проверили остальные ключи кеша и нашли ещё два без тенанта; (д) добавили правило ревью: любой ключ кеша содержит идентификатор тенанта.
  4. Результат. Утечка устранена, а повторный анализ показал, что риск был системным: три ключа из одиннадцати формировались без контекста клиента.

Вывод: фильтрация в запросах не покрывает кеш, фоновые задачи и соединения таблиц. Утечка между клиентами чаще всего возникает в слоях, добавленных позже основного кода, а защита на уровне базы ограничивает последствия пропуска.

FAQ

Достаточно ли фильтрации в ORM? Нет. ORM защищает от пропуска в типичных запросах, но сырой SQL, соединения таблиц и кеш остаются вне его контроля.

Откуда брать идентификатор тенанта? Из аутентифицированного контекста. Значение, переданное клиентом, проверяется на соответствие правам, а не используется напрямую.

Как тестировать отсутствие утечек? Тестами на доступ к данным другого тенанта с ожиданием ошибки, а также проверкой ключей кеша и результатов фоновых задач.

Что делать, если часть таблиц не содержит tenant_id? Справочники, общие для всех клиентов, не требуют фильтрации, но должны быть явно помечены как общие, чтобы отсутствие условия не выглядело ошибкой.

Источники: PostgreSQL docs (row security), SQLAlchemy docs (query guide), OWASP Top 10 — ссылки в шапке.