Представим небольшой SaaS для выставления счетов. У него один адрес API, GET /api/invoices/1042, и два клиента: типографию «Лист» и студию «Вектор». У каждой компании своя нумерация, поэтому счёт 1042 есть и там и там. Запросы выглядят одинаково, код обработчика один, таблица одна. Различаются они лишь тем, кто спрашивает. Если это различие живёт только в строке запроса или в аккуратности разработчика, рано или поздно оно сломается: кто-то напишет новый отчёт и забудет одно условие.

Эта статья про то, как сделать так, чтобы граница между арендаторами существовала глубже интерфейса: в схеме данных, в ролях базы и в тестах. Мы пройдём путь одного запроса от токена до строки на диске, сравним три способа разделения, разберём политики построчной защиты (RLS), а потом займёмся неприятным: пулом соединений, фоновыми заданиями и выгрузкой для администратора.

Один запрос, два арендатора: откуда берётся личность

Начнём с самого запроса. Пользователь типографии присылает токен, сервер его проверяет и узнаёт: это сотрудник арендатора «Лист». Здесь и возникает главное решение: идентификатор арендатора должен происходить из проверенной сессии, а не из тела запроса, заголовка или параметра пути. Если клиент может написать ?tenant=vector и приложение послушно подставит это значение в запрос, никакой изоляции нет, есть вежливая просьба.

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

Теперь вопрос: что делает база, когда ей приходит SELECT * FROM invoices WHERE number = 1042? В обычной настройке PostgreSQL ничего не знает о типографии и студии. Она видит одну роль приложения и одну таблицу. Если в запросе нет условия по арендатору, вернутся оба счёта, а приложение должно будет заметить это и отбросить лишнее. То есть вся защита держится на том, что каждый разработчик в каждом запросе помнит о границе. Пока в проекте три таблицы и один автор, это работает. Когда таблиц сорок, а запросов столько, что их пишут и ORM, и аналитики, и скрипты миграции, память перестаёт быть надёжным механизмом.

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

Базы, схемы и колонка: три способа сказать «это ваше»

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

Отдельная база на арендатора

Здесь граница самая жёсткая. Подключение к базе «Листа» физически не видит таблиц «Вектора». Права выдаются на уровне баз, ошибка в запросе не может перетащить чужие строки, потому что их там нет. Восстановить одного клиента из резервной копии тоже просто: это один дамп, одна база.

Цена: приложение должно уметь выбирать, к какой базе подключаться, и держать пулы соединений на каждую. У каждой базы свои служебные процессы и свой объём метаданных. Миграция схемы превращается в цикл: прогнать изменение по всем базам, обработать случаи, когда на сороковой из пятидесяти что-то пошло не так, и держать версии схемы в согласованном виде. Сквозная аналитика по всем клиентам требует отдельной выгрузки. Для десятка крупных клиентов с жёсткими требованиями такой путь оправдан. Для сотен маленьких он быстро становится похож на содержание зоопарка.

Схема на арендатора

Середина: одна база, внутри схемы tenant_list и tenant_vector с одинаковым набором таблиц. Приложение при запросе выставляет search_path на нужную схему, и неквалифицированные имена таблиц указывают на данные этого клиента. Резервное копирование и восстановление одной схемы тоже возможны, права можно выдавать по схемам.

Подводные камни: миграции всё равно нужно применять к каждой схеме; число объектов в каталоге растёт пропорционально числу клиентов (таблицы, индексы, последовательности умножаются на количество арендаторов); а граница держится на корректной установке search_path в каждом соединении. Если приложение оставило путь поиска от прошлого клиента, следующий запрос пойдёт не в те таблицы. Ровно такая же проблема с пулом ждёт нас дальше в варианте с RLS, и это не случайное совпадение: любой способ, где контекст клиента хранится в состоянии соединения, требует дисциплины при его возврате в пул.

Общие таблицы и tenant_id

Самый компактный вариант: одна таблица invoices, в ней колонка tenant_id, везде составные ключи вроде (tenant_id, id). Миграция одна, индексы общие, число объектов в каталоге не зависит от числа клиентов. Аналитика по всем арендаторам естественна. Неудобно другое: сама по себе колонка ничего не защищает. Она лишь позволяет фильтровать. Фильтровать же нужно в каждом запросе вручную, и именно здесь ошибка стоит дороже всего.

Три способа разделения на примере счётного SaaS
ПодходГде проходит границаЧто болит
База на арендатораПодключение и права на базуПулы, миграции по циклу, сквозная аналитика
Схема на арендатораПрава на схему и search_pathРост числа объектов, миграции по схемам, состояние соединения
Общие таблицы + tenant_idУсловие в запросеЗабытый фильтр, если нет серверной проверки

Выбор зависит от клиентов. Если арендаторов немного, они крупные и договор требует отдельных ключей и отдельного восстановления, изоляция на уровне баз или схем понятна. Если арендаторов много, они похожи друг на друга, а новый клиент должен появляться одной строкой в таблице, а не новой схемой, практичнее общие таблицы. Но тогда границу надо поручить самой базе. Для этого и существует RLS.

tenant_id: колонка, которая не знает, что она защита

Давайте честно посмотрим на вариант с колонкой. Допустим, в таблице invoices есть tenant_id, а в коде есть слой доступа, который добавляет WHERE tenant_id = $1. Всё работает. Через месяц появляется экран «найти счёт по номеру», и новый разработчик пишет: SELECT * FROM invoices WHERE number = $1. Тесты проходят, потому что в тестовой базе один арендатор. На проде студия «Вектор» ищет номер 1042 и получает счёт типографии.

Заметьте: никто не был злонамерен и ничего экзотического не произошло. Колонка tenant_id лежала на месте. Она просто не участвовала в запросе, а база не имела причин настаивать. Вот и формулировка, которую стоит запомнить: наличие tenant_id описывает, чьи это данные, но не запрещает читать чужие. Защитой становится только серверная проверка, которая применяется независимо от того, помнит ли о ней конкретный запрос.

Такая проверка может жить в приложении (обязательный слой доступа, который иначе не даёт выполнить запрос) и в базе. Лучше иметь оба слоя: слой приложения даёт понятные ошибки и быстрые отказы, база ловит то, что слой приложения пропустил.

RLS: когда база сама дописывает условие

Политики построчной защиты в PostgreSQL устроены так: вы включаете RLS для таблицы и описываете политики, которые решают, какие строки роль может видеть и какие может добавлять или менять. Подробное описание есть в документации PostgreSQL о политиках безопасности строк. Для нашей задачи важны три положения оттуда.

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

Вот минимальная схема для нашего сервиса. Таблица, включение RLS и одна политика на все команды для роли приложения:

CREATE TABLE invoices (
  tenant_id uuid NOT NULL,
  id bigint NOT NULL,
  number text NOT NULL,
  amount_cents bigint NOT NULL,
  PRIMARY KEY (tenant_id, id)
);

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;

CREATE POLICY invoices_by_tenant ON invoices
  FOR ALL
  TO app_user
  USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

Разберём, что здесь происходит. Выражение в USING определяет, какие существующие строки видны и доступны для изменения и удаления. Выражение в WITH CHECK проверяет новые и изменённые строки: нельзя вставить счёт с чужим tenant_id и нельзя «перекинуть» свою строку другому арендатору обновлением. Значение арендатора берётся из параметра сессии app.tenant_id. Функция current_setting с вторым аргументом true не падает, когда параметр не задан, а возвращает пустое значение. Обёртка nullif(..., '') нужна потому, что параметр, который уже устанавливался в соединении, после завершения транзакции может превратиться в пустую строку, а приведение пустой строки к uuid вызовет ошибку. С nullif мы получаем NULL, сравнение с ним даёт «неизвестно», и строки не проходят. Итог: если контекст не выставлен, запрос видит ноль строк, а не все.

Теперь тот самый новый экран «найти счёт по номеру» с забытым фильтром: SELECT * FROM invoices WHERE number = '1042'. База сама добавит условие политики, и «Вектор» получит только свой счёт. Ошибка разработчика перестала быть утечкой.

Роли, которые обходят правила

Теперь о том, что делает красивую политику бесполезной. По документации PostgreSQL, суперпользователь и роли с атрибутом BYPASSRLS обходят RLS всегда. Владелец таблицы тоже обычно не подчиняется политикам, если для таблицы не включён режим FORCE ROW LEVEL SECURITY. В примере выше мы включили оба режима: и ENABLE, и FORCE.

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

  • Приложение не должно подключаться суперпользователем. Это звучит очевидно, но в разработке частая картина: один пользователь postgres на всё, а политики тестируются под ним. Тест будет зелёным по неправильной причине: политики для него просто не вычисляются.
  • Приложение не должно подключаться владельцем таблиц. Роль, которая создаёт таблицы и применяет миграции, и роль, которая обслуживает запросы клиентов, должны быть разными. Владельцем таблиц остаётся миграционная роль, а у app_user только права SELECT, INSERT, UPDATE, DELETE на нужные таблицы. FORCE остаётся дополнительным ремнём: даже если владелец когда-нибудь окажется в пуле, политики будут применяться и к нему.
  • Роли без BYPASSRLS для обычной работы. Атрибут нужен ограниченному кругу задач вроде административной выгрузки, о которой ниже, и должен выдаваться осознанно и отдельной роли.

Есть ещё одна оговорка из той же документации, о которой часто забывают: TRUNCATE и REFERENCES не подчиняются построчной фильтрации RLS. Что это значит на практике?

С TRUNCATE всё просто: это команда, которая очищает таблицу целиком, она не работает построчно и политики не видит. Поэтому право TRUNCATE роли приложения выдавать нельзя. Стандартный набор прав, о котором мы говорили выше, его и не включает; проблемы возникают, когда кто-то выдаёт ALL PRIVILEGES «чтобы заработало».

С REFERENCES тоньше. Внешний ключ проверяет существование строки в другой таблице, и эта проверка идёт мимо построчных политик. В многоарендной схеме из этого вытекает конкретный риск: если у таблицы payments внешний ключ ссылается только на invoices(id), арендатор теоретически может создать платёж, привязанный к счёту другого арендатора, если знает или угадает его идентификатор. Политика на payments проверит, что tenant_id платежа свой, но ключ спокойно примет чужой счёт. Лечится это структурно: внешние ключи делаются составными, FOREIGN KEY (tenant_id, invoice_id) REFERENCES invoices (tenant_id, id). Тогда ссылка на счёт другого арендатора невозможна в принципе, потому что пара значений не сойдётся с тем, что стоит в платеже. Это тот самый случай, когда tenant_id в первичных ключах оправдывает себя: он нужен не для фильтрации, а чтобы связи не пересекали границу.

И ещё одна рамка, чтобы не обмануться: RLS не заменяет проверку авторизации в приложении. Политика отвечает на вопрос «какому арендатору принадлежит строка», а не «можно ли этому сотруднику смотреть финансовые документы» или «этот пользователь только читает». Права внутри арендатора, роли пользователей, лимиты и прочее остаются на уровне приложения. RLS закрывает конкретную и очень дорогую категорию ошибок: переход через границу между клиентами.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Соединение из пула: как не подарить следующему клиенту чужой контекст

Теперь самая коварная часть. Наша политика читает app.tenant_id из настроек сессии. А соединения с базой обычно живут в пуле и обслуживают разных пользователей по очереди. Что произойдёт, если мы выставили контекст для «Листа» на уровне сессии, запрос отработал, соединение вернулось в пул, и его взял следующий запрос от «Вектора»? Если новый запрос не переустановил контекст, он увидит данные «Листа». Это и есть утечка, которая переезжает вместе с соединением, как забытый в такси зонтик.

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

BEGIN;
SELECT set_config('app.tenant_id', '11111111-1111-1111-1111-111111111111', true);
SELECT id, number, amount_cents FROM invoices WHERE number = '1042';
COMMIT;

Третий аргумент true означает, что значение локально для транзакции: после COMMIT или ROLLBACK оно сбрасывается. Следующая транзакция на том же соединении начнёт с пустым значением, и благодаря nullif в политике увидит ноль строк, пока не выставит свой контекст. Это и есть поведение «закрыто по умолчанию», которое мы хотели.

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

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

Есть и обратная сторона: транзакция на каждый запрос означает чуть больше сетевых обменов (BEGIN, установка контекста, сам запрос, COMMIT). На практике их можно сократить, отправляя команды одной пачкой или оформив установку контекста как часть первой команды, но как это отзовётся на нагрузке, нужно измерять на своей системе. Универсального числа тут нет.

Не только список: вставка, изменение, удаление и ночной воркер

Проверка «открыл список и увидел свои счета» ловит только чтение. Но граница арендаторов нарушается и при записи, а запись устроена сложнее.

Создание

При вставке у приложения два варианта: явно передавать tenant_id или полагаться на значение по умолчанию, взятое из контекста. Первое привычнее, второе надёжнее: можно задать для колонки DEFAULT nullif(current_setting('app.tenant_id', true), '')::uuid, и приложение вообще перестанет передавать арендатора в INSERT. Если же оно передаст чужой, WITH CHECK отклонит строку. Проверьте оба пути: вставка без tenant_id должна получить свой, вставка с чужим должна упасть с ошибкой нарушения политики.

Изменение

Здесь два сценария. Первый: UPDATE invoices SET amount_cents = 0 WHERE id = 7, где id = 7 принадлежит другому арендатору. Благодаря USING строка для вашей роли просто невидима, и команда не затронет ни одной строки (не ошибка, а «0 обновлено»). Приложению важно трактовать нулевое число затронутых строк как «не найдено», а не как успех. Второй сценарий опаснее: UPDATE invoices SET tenant_id = '…чужой…' WHERE id = 7 по своей строке. Строка видима, обновление применимо, но новая версия строки принадлежала бы другому арендатору. Без WITH CHECK можно было бы «подарить» чужому клиенту мусорную запись или вытеснить свои данные в чужую область. С WITH CHECK база отклонит новую версию.

Удаление

Для DELETE работает USING: чужие строки невидимы и не удаляются. Но если у вас каскадные удаления по внешним ключам, убедитесь, что ключи составные, как мы обсуждали, иначе каскад следует по идентификатору, а не по паре с арендатором.

Фоновые задания

Самый частый источник забытых дыр. Веб-запрос проходит через слой аутентификации и получает контекст. А что у ночного воркера, который рассылает напоминания о неоплаченных счетах? Ни токена, ни пользователя. Остаются два осмысленных пути.

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

Второй: периодическое задание «для всех». Вместо одного большого запроса без контекста стоит сначала получить список арендаторов (из отдельной таблицы арендаторов, к которой у воркера есть доступ), а затем в цикле обрабатывать каждого в своей транзакции со своим контекстом. Так политики продолжают работать, ошибка у одного клиента не ломает остальных, а нагрузку можно разбить на порции. Воркеру не нужна обходная роль, если он уважает границы, и вряд ли стоит её выдавать ради удобства.

Если очередь хранится в той же базе, таблицу заданий тоже можно защитить RLS. Но воркер, который достаёт «следующее задание любого арендатора», по определению не знает арендатора заранее, а потому в таком случае очередь остаётся служебной таблицей без политик, к которой доступ есть только у воркера, а не у роли обработчиков веб-запросов. Главное, чтобы эта граница ролей была осознанной, а не случайной.

Проверка двумя арендаторами: тест, который ловит настоящие ошибки

Теперь соберём всё в тест. Идея проста и вытекает из всего сказанного: один арендатор в тестовых данных не может поймать ни одной утечки, потому что утекать некому. Нужны как минимум двое с пересекающимися данными: одинаковыми номерами счетов, одинаковыми названиями, а иногда и одинаковыми идентификаторами внутри своих областей.

Набор данных для нашего примера: арендаторы A и B; у каждого счёт с номером 1042, у A он на 100, у B на 900; у A ещё счёт 1043. Тест выполняется под ролью приложения (не суперпользователем и не владельцем таблицы), потому что именно эту роль нужно проверять. Ниже перечислено, что стоит проверить.

  1. Чтение по тому же запросу. Под контекстом A запрос по номеру 1042 возвращает одну строку со значением 100; под контекстом B возвращает одну строку со значением 900. Общий список под A содержит 1042 и 1043, под B только 1042.
  2. Чтение без контекста. Транзакция без выставленного арендатора возвращает ноль строк. Это проверяет поведение «закрыто по умолчанию» и корректность nullif.
  3. Контекст не переезжает. Выставить контекст A в транзакции, завершить её, на том же соединении начать новую без контекста: строк быть не должно. Это проверка именно пула.
  4. Вставка с чужим арендатором. Под контекстом A попытка вставить строку с tenant_id арендатора B завершается ошибкой политики.
  5. Обновление чужой строки и перенос своей. Под A обновление строки B затрагивает ноль строк; обновление своей строки со сменой tenant_id на B завершается ошибкой.
  6. Удаление чужой строки. Под A удаление счёта B затрагивает ноль строк, а строка по-прежнему видна под B.
  7. Составные ключи. Попытка создать под A платёж, ссылающийся на счёт B, отклоняется внешним ключом.
  8. Фоновое задание. Запуск воркера для задания арендатора A не меняет и не читает данные B; задание без арендатора отклоняется.
  9. Права роли. Роль приложения не может выполнить TRUNCATE и не является суперпользователем, не имеет BYPASSRLS и не владеет таблицами.

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

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

Выгрузка для администратора: исключение, которое должно быть оформлено

Рано или поздно кому-то понадобится увидеть все данные сразу: поддержке, чтобы разобрать обращение, финансам, чтобы сверить итоги, инженеру, чтобы сделать миграцию или экспорт для конкретного клиента по его просьбе. Соблазн велик: взять роль владельца или суперпользователя и выполнить запрос. Но этим самым вы стираете границу, которую только что строили, и делаете это без следа в журнале приложения.

Правильнее признать, что такая операция существует, и описать её как отдельную разрешённую. Несколько вариантов, от более строгого к менее.

  • Выгрузка одного арендатора через тот же механизм. Самый частый запрос на деле звучит как «выгрузить данные клиента X». Это не требует обхода RLS: административный инструмент выставляет контекст X и выполняет обычные запросы под обычной ролью. Так выгрузка работает по тем же правилам, что и сам клиент, и никакой обход не нужен.
  • Отдельная административная роль с явными политиками. Если нужен срез по нескольким арендаторам, можно создать отдельную роль и дать ей собственную политику только на чтение. Преимущество в том, что права ограничены и описаны в одном месте. Политики для разных ролей и команд PostgreSQL умеет задавать отдельно, как следует из документации.
  • Роль с BYPASSRLS для редких задач. Это крайний вариант, и его атрибуты обходят политики полностью. Если он нужен, роль заводится отдельно, без возможности подключения из веб-приложения, используется только из административного контура с отдельным подключением, с учётом в журнале того, кто и когда её применил. Выдавать такой атрибут роли, под которой работает обычный сервис, не стоит ни при каких удобствах.

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

Где всё это живёт: немного о ресурсах

Всё сказанное выше не требует особой экзотики от сервера, но кое-что от окружения всё же зависит. Для настройки ролей, режима пулера и параметров PostgreSQL удобен полный контроль над окружением: на VPS MAATRIX предоставляется root-доступ, диски на NVMe и процессоры AMD EPYC, так что пулер, роли и конфигурацию базы можно настроить под свою схему. Когда арендаторов становится больше, разница между общими таблицами и схемой на клиента ощущается в памяти, числе объектов каталога и времени миграций, поэтому тарифы от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) до Djed (16 vCPU, 32 ГБ RAM, 320 ГБ NVMe) стоит рассматривать как варианты для замеров на собственной нагрузке: сами по себе ресурсы не равны обещанию ёмкости, и сколько арендаторов выдержит конкретное приложение, показывают только нагрузочные тесты. Про установку и настройку самой базы под нагрузку подробнее написано в материале VPS для PostgreSQL: установка и тюнинг под нагрузку.

Заметим и то, что никакое окружение не заменит ролевой дисциплины: изоляция данных арендаторов в этой статье держится на политиках, ролях и тестах, а не на размере сервера.

Тот же запрос, два ответа

Вернёмся к началу. GET /api/invoices/1042, один адрес, один обработчик, одна таблица. Токен типографии «Лист» проходит проверку, слой аутентификации узнаёт арендатора и открывает транзакцию: первым действием выставляется локальный контекст, затем выполняется выборка по номеру. Политика дописывает условие сама, и «Лист» получает свой счёт на 100. Транзакция завершается, контекст исчезает вместе с ней.

Следом приходит запрос студии «Вектор» с тем же адресом и тем же номером. Соединение может быть тем же самым, но контекста на нём уже нет: новая транзакция выставляет свой, и «Вектор» получает свой счёт на 900. Если же кто-то забыл фильтр в запросе, поставил в пул чужой контекст или перепутал роль, тест на двух арендаторах покраснеет раньше, чем это увидит клиент.

-- Лист
BEGIN;
SELECT set_config('app.tenant_id', '11111111-1111-1111-1111-111111111111', true);
SELECT number, amount_cents FROM invoices WHERE number = '1042';  -- 100
COMMIT;

-- Вектор
BEGIN;
SELECT set_config('app.tenant_id', '22222222-2222-2222-2222-222222222222', true);
SELECT number, amount_cents FROM invoices WHERE number = '1042';  -- 900
COMMIT;

Один и тот же текст запроса, два разных ответа, и каждый клиент видит только свою строку.