16 июн. 2025 г.·8 мин чтения

Postgres row-level security: сначала проверьте изоляцию тенантов

Row-level security в Postgres помогает изолировать тенанты, но support-инструменты, экспорты и фоновые задачи часто обходят слабые тесты. Узнайте, что проверять в первую очередь.

Postgres row-level security: сначала проверьте изоляцию тенантов

Почему утечки между тенантами все еще происходят

Большинство утечек между тенантами приходят не через основной экран, а через боковые пути. Команда может закрыть главное приложение, добавить Postgres row-level security и при этом все равно утечь данными через один запрос, который никто внимательно не проверил.

Обычно схема простая. Люди тестируют happy path и игнорируют все, что вокруг него. Вошедший в систему пользователь видит только свои записи, и приложение выглядит безопасным. Потом сотрудник поддержки открывает внутренний экран, вводит email, а инструмент выполняет широкий запрос без проверки тенанта. Одного пропущенного фильтра достаточно, чтобы показать данные другой компании.

Support-инструменты особенно проблемные, потому что команды делают их быстро. Они часто находятся вне основного потока запросов, используют прямой доступ к базе или вызывают вспомогательные функции, которые обходят проверки из интерфейса продукта. Инструмент, который должен решить проблему одного клиента за 30 секунд, может стать самым простым способом заглянуть в чужой аккаунт.

Экспорты и поиск ломаются похожим образом. CSV-экспорт может соединять пять таблиц и пропустить одно условие по тенанту. Поиск может строить общий индекс, а потом возвращать строки, которые совпадают по запросу, но принадлежат другой компании. Эти пути кажутся безобидными, потому что они только читают данные, но утечки при чтении тоже разрушают доверие.

Фоновые задачи добавляют еще одну слабую точку. Биллинг запускается ночью. Индексация поиска идет каждый час. Задачи очистки просматривают старые записи. Команды часто дают этим jobs широкий доступ, потому что так быстрее, чем точно сопоставлять каждую задачу с теми строками, которые она должна затронуть. Как только это происходит, задача может читать данные между тенантами, а любая ошибка в логах, повторных попытках, письмах или сгенерированных файлах может распространить утечку дальше.

Одни и те же слабые места повторяются снова и снова: внутренние админские экраны, CSV- и PDF-экспорты, поисковые и отчетные запросы, плановые задачи и queue workers, а также service roles, которые используют ради удобства.

Небольшой B2B SaaS может сделать все правильно в клиентском приложении и все равно провалиться здесь. Правила политики помогают, но сами по себе их недостаточно. Нужно проверять каждый путь, который касается данных тенанта, особенно скучные внутренние сценарии, о которых никто не думает, пока support не найдет проблему.

Что покрывает row-level security

Postgres row-level security работает на уровне таблицы. Когда запрос доходит до таблицы, Postgres проверяет политику для этой таблицы и решает, какие строки текущая роль может видеть или менять. Это полезно, но покрывает меньше, чем ожидают многие команды.

Частая ошибка — думать, что одна политика покрывает вообще все действия. Это не так. Чтение строк и изменение строк — разные задачи. Пользователю может быть разрешено читать запись клиента, но не разрешено вставлять новую, обновлять ее или удалять.

Нужны отдельные проверки для каждого типа доступа:

  • SELECT контролирует, какие существующие строки запрос может читать.
  • INSERT контролирует, какие новые строки роль может добавлять.
  • UPDATE контролирует, какие старые строки роль может выбрать и какие новые значения сохранить.
  • DELETE контролирует, какие строки роль может удалять.

Это разделение особенно важно в B2B-приложении. Сотруднику поддержки может быть разрешено смотреть тикеты tenant A для отладки. Если ваша политика SELECT жесткая, а UPDATE слишком свободная, этот сотрудник все равно может случайно изменить статус или перенести данные между тенантами.

Row-level security также не делает автоматически безопасным каждый SQL-путь. Views, join'ы и функции могут менять результат еще до того, как он попадет в приложение. View может соединять данные тенанта с общей таблицей и показывать поля, которые вы не собирались раскрывать. Функция может выполняться под более сильной ролью, чем у вызывающего кода, особенно если используется SECURITY DEFINER. В таком случае чистая логика политик перестает защищать данные так, как вы ожидали.

Service roles — еще одна слабая точка. Если support-инструмент, скрипт экспорта или фоновый worker подключается с широкой ролью, он может обходить те же правила, которым следуют обычные пользователи приложения. В Postgres роли с BYPASSRLS полностью игнорируют политики. Владельцы таблиц тоже могут обходить их, если не включить принудительное применение row-level security на таблице.

Так что row-level security — это фильтр на уровне данных, а не забор вокруг всей системы. Он сильно помогает, но только если каждый путь к данным и каждая роль проходят через одни и те же проверки.

Составьте карту всех путей к данным тенанта

Большинство команд проверяют клиентское приложение и пропускают боковые двери. Именно там и появляются утечки. Support-поиск, финансовый экспорт или ночная задача могут игнорировать правила, которые вы аккуратно настроили в основном продукте.

Начните с простой инвентаризации. Одна строка должна описывать один способ движения данных: загрузка страницы, API-запрос, отчет, поисковый запрос, обработчик webhook или плановая задача. Если вы используете Postgres row-level security, эта карта покажет, что именно вам нужно тестировать.

Обычной таблицы достаточно. Для каждого пути запишите, что его запускает, под какой ролью он работает, к каким таблицам, views и functions обращается и какой tenant-фильтр вы ожидаете.

Не останавливайтесь на пользовательских сценариях. Внутренние инструменты часто имеют самый широкий доступ и самое слабое ревью. Support может искать по email. Sales может открывать пробные аккаунты. Finance может выгружать данные по счетам сразу по многим тенантам. Каждый такой путь должен быть отдельной строкой в инвентаризации, даже если он кажется безобидным.

То же самое относится к фоновым задачам. Billing sync, аналитические rollup'ы, отправка писем, индексатор поиска — все они могут читать большие наборы строк, пока никто не смотрит. Такие jobs часто используют service roles, и именно поэтому им нужен особый контроль. Запишите, какая роль используется и должна ли она видеть один тенант, несколько тенантов или все тенанты.

Простой пример помогает. Допустим, в B2B-приложении есть accounts, users, invoices и support_notes. Экран счетов для клиента может читать invoices через API, который фильтрует по tenant_id. Support-консоль может читать accounts, users и support_notes под staff-ролью. Ежемесячный экспорт может соединять invoices и accounts под system-ролью. Это три разных пути, даже если все они в итоге показывают данные по счетам.

Если вы не можете показать полный список путей, значит вы гадаете. Именно так утечки из support-инструментов попадают в продакшен.

Подготовьте тестовых тенантов, которые выявляют утечки

Синтетические тестовые данные бесполезны, если они слишком чистые. Если у каждого тенанта разные имена, разные домены email и аккуратные записи, плохие политики могут выглядеть правильными. Вам нужны тестовые тенанты, которые легко перепутать, потому что реальные support-инструменты, экспорты и jobs часто ломаются на грязных совпадениях.

Начните как минимум с двух тенантов, которые специально похожи друг на друга. Дайте обоим тенантам пользователя по имени «Alex Kim». Повторите один и тот же внешний email в двух записях, если ваш продукт допускает приглашенных пользователей, billing contacts или пересланную поддержку по почте. Создайте счета с одинаковой суммой, тикеты с одинаковой темой и audit entries с одинаковыми названиями действий. Когда поисковая строка, CSV-экспорт или админский экран утекут данными, такие дубликаты сделают проблему очевидной.

Также заполните каждый тенант строками, о которых команды обычно забывают: активные и soft-deleted пользователи, открытые и закрытые support tickets, оплаченные и неоплаченные счета, возвращенные счета, записи audit log, привязанные к удаленным объектам, а также записи с null-полями или старыми timestamp'ами.

Soft-deleted данные вызывают больше проблем, чем ожидают многие команды. Политика может блокировать обычное чтение, но все равно показывать удаленные строки через отчетные запросы или support-экраны. Audit-таблицы тоже часто застают людей врасплох. Команды обычно защищают клиентские таблицы и забывают, что audit entries все еще содержат tenant_id, email-адреса или названия объектов.

К staff-аккаунтам нужно относиться так же. Создайте один аккаунт с обычными правами tenant admin, один с ограниченным доступом к биллингу, один с support-доступом и один внутренний service account, если приложение его использует. Затем проверьте, что каждый из них может читать, искать, экспортировать и обновлять. Пользователь поддержки, которому нужно только смотреть тикеты, легко увидит счета, если join'ы или views пропускают tenant-фильтр.

Именно здесь row-level security начинает доказывать свою пользу. Если tenant A и tenant B выглядят почти одинаково, ваши тесты перестают проходить случайно.

Проверяйте политики в фиксированном порядке

Проверьте не только экраны
Проверьте SQL за экспортами, отчетами и внутренними инструментами вместе с опытным CTO.

С Postgres row-level security порядок тестов почти так же важен, как сама политика. Если прыгать между случайными экранами и ad hoc-запросами, вы упускаете закономерности. Фиксированная последовательность помогает легче заметить утечки и проще их воспроизвести.

Сначала перед каждым прогоном запишите ожидаемый результат. Формулируйте просто: «tenant A видит 12 счетов», «tenant B видит 12 других счетов» или «обновление должно быть запрещено для строки из другого тенанта». Этот шаг убирает частую ошибку, когда плохой результат начинают объяснять уже после того, как его увидели.

Используйте два тенанта с похожими данными. Дайте обоим похожие названия клиентов, номера счетов, значения статусов и даты. Если у tenant A и tenant B есть счет под названием INV-1001, поиск и фильтры становится намного труднее подделать, а плохие политики проявляются быстро.

Хорошая последовательность проста:

  1. Войдите как tenant A и откройте все экраны, где видны данные тенанта.
  2. Повторите те же экраны и действия как tenant B.
  3. Запустите поиск, фильтры, массовые действия и прямые запросы по ID записи или public slug.
  4. Проверьте правила чтения, вставки, обновления и удаления по отдельности.

Не объединяйте write-тесты в один большой поток. Чтение может работать, а обновление — утекать. Вставка может пройти, но новая строка может попасть не в тот тенант. Правила удаления часто ломаются иначе: строка выглядит скрытой, но удаление все равно проходит, потому что вспомогательная функция или условие политики слишком свободные.

Делайте заметки по каждому действию. Если пользователь ищет по email, фильтрует по «paid», открывает карточку, меняет поле и экспортирует выбранный набор, запишите ожидаемое поведение еще до того, как нажмете что-то. Потом сравните оба тенанта рядом.

Это повторяется. В этом и смысл. Повторение ловит тихие баги, особенно когда оба тенанта нарочно почти одинаковые.

Проверьте support-инструменты, экспорты и поиск

Support-экраны часто утекут данными раньше, чем само основное приложение. Staff-аккаунт может открыть глобальный поиск, ввести email и получить совпадения из всех тенантов, потому что поисковый запрос обращается к отдельной таблице или индексу. Если доверять row-level security, не проверив эти пути, можно пропустить утечку, которой support пользуется каждый день.

Для этого теста используйте реальный staff-аккаунт, а не super admin. Ищите по email, имени клиента, номеру счета и всему остальному, что support-специалист попробует под давлением. Затем проверьте две вещи: список результатов показывает только записи из активного тенанта, а детальная страница не раскрывает лишние данные после клика.

Простой сбой выглядит так: сотрудник ищет [email protected], помогая Acme, а поиск еще и возвращает тикет от Beta Logistics, потому что запрос к индексу проигнорировал tenant_id. Сотрудник не сделал ничего необычного. Это сделал инструмент.

К экспортам нужен такой же подход. Запускайте CSV- или spreadsheet-экспорт по одному тенанту за раз и открывайте файл, а не только сообщение об успехе. Проверяйте количество строк, названия тенантов, ID аккаунтов и скрытые колонки, которые интерфейс вообще не показывает. Код экспорта часто работает в background worker'е или через service role, поэтому он может обходить те же правила, что и приложение.

Сохраненные отчеты и дашборды могут утекать тише. Сгруппированные count'ы, totals и трендовые графики выглядят безобидно, но даже один count между тенантами уже раскрывает информацию. Если staff-пользователь фильтрует по одному клиентскому аккаунту, все числа на странице должны меняться вместе с этим фильтром. Кэшированные отчеты требуют особого внимания, потому что они могут повторно использовать результат более широкого запроса.

Админские override'ы нужно проверять жестко. Сотрудникам иногда нужен более широкий обзор, но действие должно оставаться узким и очевидным.

  • Требуйте явного выбора тенанта перед запуском support-поиска.
  • Ограничьте экспорты выбранным тенантом и записывайте tenant_id в параметры job.
  • Показывайте, когда staff использует override, и логируйте, кто это сделал и зачем.
  • Блокируйте широкие wildcard-запросы, если роль действительно в них не нуждается.

Если ваш admin-раздел, экспорты и отчеты ведут себя корректно с обычным staff-аккаунтом, ваши правила гораздо с большей вероятностью выдержат реальную работу по безопасности B2B SaaS.

Проверьте фоновые задачи и service roles

Проверьте jobs и service roles
Проверьте workers, повторы задач и пути кэша на корректную область тенанта.

Фоновые задачи часто обходят ограждения, которые использует приложение. Queue worker, cron-задача или webhook consumer может подключаться с широкой database role и читать строки сразу по всем тенантам. Именно здесь многие настройки row-level security и ломаются.

Сделайте простой список всех процессов, которые касаются данных тенанта. Включите туда запланированные отчеты, billing runs, индексацию поиска, email digests, задачи импорта и экспорта, повторные попытки webhook и админские скрипты. Для каждого процесса проверьте точную database role, которую он использует, и правила политик, привязанные к этой роли.

Каждая задача должна получать tenant_id на входе. Не позволяйте worker'у угадывать область тенанта по первой попавшейся строке, кэшированной сессии или аккаунту по умолчанию. Если один batch обрабатывает несколько тенантов, код должен намеренно переключать контекст тенанта на каждом шаге.

Затем проверьте грязные сценарии:

  • Повторите упавшую задачу со старыми данными в payload.
  • Повторно проиграйте webhook после того, как тенант изменил настройки.
  • Запустите один batch с записями из двух тенантов в одной очереди.
  • Прогрейте кэш, а потом запросите тот же отчет как другой тенант.
  • Создайте temp table в одном запуске и прочитайте ее в следующем.

Эти случаи кажутся мелкими, но именно они вызывают реальные утечки. Хороший пример — ночная задача экспорта. На первом запуске она может взять правильный тенант, а на повторе — переиспользовать ключ кэша или temp table и смешать в файле строки другой компании. Prebuilt reports требуют того же внимания. Если задача записывает данные в summary tables, проверьте, кто может пересобирать эти таблицы и кто сможет читать их позже.

К service roles нужно относиться особенно подозрительно. Некоторые команды дают worker'ам роль, которая может обходить политики, потому что так проще писать jobs. Такой компромисс часто возвращается утечкой. Если для обслуживания нужен широкий role, держите этот путь узким, логируйте имя роли и область тенанта и заставляйте задачу завершаться ошибкой, когда tenant_id отсутствует. Сломанная задача должна остановиться, а не угадывать.

Простой B2B-пример

Сотруднику поддержки нужен CSV со всеми открытыми тикетами для Acme. Экран в приложении выглядит нормально. Все строки на странице принадлежат Acme, и сотрудник не видит ничего странного.

Проблема начинается в запросе на экспорт. Он берет тикеты, а потом соединяет таблицу users, чтобы добавить email и имя клиента. У таблицы тикетов есть фильтр по тенанту, но у соединенной таблицы users его нет. У одного клиента Beta случайно есть запись пользователя, которая совпадает с условием join'а, и его email попадает в экспорт Acme.

Звучит мелко, пока не представишь этот файл в реальном рабочем процессе. Кто-то скачивает его, отправляет не тому менеджеру по аккаунту, и теперь данные одного клиента лежат в почте другого клиента. Веб-приложение по-прежнему выглядит правильно, поэтому команда предполагает, что Postgres row-level security работает везде как надо.

Это не так. Support-экспорты, admin search и batch jobs часто выполняют другие запросы, чем те, которые продуктовая команда проверяет каждый день. Чистый экран может месяцами скрывать плохой join.

Простой тест быстро это ловит. Создайте два тенанта с записями, которые выглядят почти одинаково. И Acme, и Beta должны иметь открытые тикеты. У обоих тенантов должны быть пользователи с похожими именами. У обоих должны быть тикеты, созданные в один и тот же день. Один email должен легко бросаться в глаза в экспорте.

Потом запустите экспорт под support-ролью и проверьте сырой файл, а не только страницу перед ним. Если в экспорте Acme есть хотя бы один email от Beta, изоляция провалилась.

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

Ошибки, которые скрывают плохие политики

Fractional CTO для SaaS
Получите прямую помощь с архитектурой продукта, инфраструктурой и изоляцией тенантов.

Многие команды тестируют row-level security, переходя по двум-трем клиентским экранам, видят правильные строки и считают задачу закрытой. Это упускает те места, где утечки обычно и происходят. Граница тенанта настолько прочна, насколько прочен самый незаметный путь к базе.

Одна из частых ошибок — тестировать чтение и пропускать запись. Плохая политика может правильно блокировать SELECT и при этом разрешать UPDATE, INSERT или DELETE, которые пересекают границы тенанта. Позже это проявляется как странные правки аккаунтов, сломанные отчеты или записи, которые будто бы перемещаются между клиентами без видимой причины.

Пустые демонстрационные данные тоже скрывают проблемы. Если у каждого тенанта аккуратные уникальные записи, слабые фильтры выглядят корректно. В реальных системах бывают совпадения: тот же домен email, то же название проекта, та же схема номеров счетов, те же поисковые запросы поддержки. Хорошие тестовые данные должны немного раздражать. Если у двух тенантов есть «Acme», тесты становятся намного лучше.

Внутренние инструменты часто делают ситуацию хуже. Многие команды дают support-дашбордам, админским скриптам, export jobs и back office-страницам одну и ту же широкую service role, потому что так проще. Потом одна вспомогательная функция обходит правила, которые защищают само приложение. Это часто и есть самый большой пробел.

Более безопасная проверка проходит по каждому пути отдельно: чтение и запись обычного пользователя приложения, поиск и экспорт в support-инструменте, фоновые задачи, которые синхронизируют, считают биллинг или отправляют уведомления, админские действия с повышенным доступом, а также cleanup scripts и одноразовые maintenance-задачи.

Еще одна ловушка — остановиться после первого хорошего результата. Политики, которые работали в прошлом месяце, могут сломаться после изменения схемы, нового join'а, переписанного запроса или нового пути поиска через индекс. Даже безобидный рефакторинг может сменить роль, убрать tenant-фильтр или перенести логику в функцию, которая выполняется с более широким доступом, чем ожидалось.

Если вы меняете таблицы, роли, запросы или код jobs, прогоняйте весь набор заново. Это все еще намного дешевле, чем узнать из тикета поддержки, что один клиент только что увидел данные другого клиента.

Проверки перед релизом, которые ловят утечки

Прежде чем считать изоляцию тенантов готовой, проверьте скучные вещи. Большинство утечек не приходят с главного экрана приложения. Они приходят из export script, staff search page, helper view или job, которая запускается в 2 часа ночи.

Если ваши правила Postgres row-level security работают только в happy path, этого недостаточно. Для каждой таблицы тенанта нужен небольшой план тестирования с понятными ожидаемыми результатами, а не только policy file в системе контроля версий.

Сделайте проверку релиза простой:

  • Назовите колонку тенанта и правило, которое ее ограничивает.
  • Для каждой таблицы тенанта запишите один разрешенный запрос и один запрещенный.
  • Проверьте все views, helper'ы и functions, которые читают данные тенанта.
  • Убедитесь, что staff-инструменты, экспорты и фоновые задачи сохраняют область тенанта.
  • Логируйте, кто читал или экспортировал данные, с tenant_id, временем и источником.

Средние пункты важнее, чем думает большинство команд. View может скрыть пропущенный фильтр. Helper function может читать строки между тенантами. Фоновая задача может работать под service role и вытащить гораздо больше данных, чем когда-либо показывает приложение. Одна из частых пропущенных проблем — CSV-экспорт, который отлично работает для клиентов, но возвращает все тенанты, когда staff запускает его из внутренней панели.

Прогоняйте те же проверки изоляции перед каждым релизом. Команды часто проверяют один раз, потом добавляют новые helper'ы и предполагают, что старые правила по-прежнему их покрывают. Чаще всего это не так. Даже небольшое изменение, например новый endpoint поиска или задача синхронизации биллинга, может открыть утечку.

Делайте audit logs простыми и конкретными. «Admin search», «invoice export» и «nightly sync job» быстро объясняют, что произошло. Общие события «read» мало помогают, когда нужно отследить утечку.

Если вам нужен второй взгляд перед запуском, Олег Сотников на oleg.is предлагает консультации в формате Fractional CTO по архитектуре стартапов, инфраструктуре и AI-first процессам разработки. Короткая проверка ролей, экспортов и фоновых задач часто обходится дешевле, чем разбирать один инцидент с утечкой между тенантами.

Часто задаваемые вопросы

Что на самом деле защищает Postgres row-level security?

RLS защищает строки, когда запрос доходит до таблицы. Он решает, какие строки роль может читать, добавлять, изменять или удалять, но сам по себе не делает безопасными все views, functions, join'ы, экспорты и jobs.

Почему утечки между тенантами происходят даже после включения RLS?

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

Какие части приложения стоит проверить в первую очередь?

Начните с внутренних инструментов, экспортов, поиска, отчетов, плановых задач, queue workers и admin scripts. Эти пути меняются быстро, получают меньше ревью и часто обращаются к данным тенанта вне обычного потока запросов.

Нужно ли отдельно тестировать SELECT, INSERT, UPDATE и DELETE?

Да. Проверьте чтение, вставку, обновление и удаление как отдельные случаи. Роль может читать правильные строки и при этом записывать данные не в тот тенант или удалять строки, к которым ей вообще не должно быть доступа.

Как лучше подготовить тестовые тенанты, чтобы ловить утечки?

Создайте как минимум два тенанта, которые специально похожи друг на друга. Повторяйте имена, номера счетов, темы тикетов и другие значения, чтобы поиск, join'ы и экспорты не проходили случайно, когда проверка tenant scope сломана.

Почему support-инструменты и поиск настолько рискованны?

Support-инструменты часто ищут по email, имени или ID в общих таблицах или индексах. Если запрос пропускает tenant scope, staff увидит совпадения из других аккаунтов, даже если клиентское приложение выглядит нормально.

Могут ли экспорты утечь, даже если экран выглядит корректно?

Да. Код экспорта часто выполняет свои join'ы в worker'е или staff-инструменте, и одно пропущенное условие утащит в файл строки из другого тенанта. Всегда открывайте сырой CSV или таблицу и проверяйте содержимое, а не только сообщение об успехе.

Что нужно проверять в фоновых задачах?

Дайте каждой задаче явный tenant_id и проверьте точную database role, которую она использует. Затем протестируйте повторы, batch'и из нескольких тенантов, повторное использование кэша, временные таблицы и webhook replay, потому что именно там код чаще всего начинает угадывать область тенанта.

Являются ли service roles проблемой для изоляции тенантов?

Да. Широкая service role может игнорировать те же правила, что и обычные пользователи приложения, а роли с BYPASSRLS полностью обходят RLS. Держите такие роли редкими, логируйте каждое использование и заставляйте jobs падать, если область тенанта не задана.

Что нужно проверить перед каждым релизом?

Перед каждым релизом снова прогоняйте те же проверки изоляции, особенно после изменений схемы, запросов, ролей, поиска или задач. Проверяйте пользовательские сценарии, staff-инструменты, экспорты, функции, views и audit logs, чтобы утечки поймали вы, а не support или клиенты.