Row Level Security (RLS) в PostgreSQL фильтрует строки по предикату политики, а предикат часто опирается на переменную сессии вроде app.current_tenant_id. PgBouncer в режиме transaction poolingрежим пулера, в котором серверное соединение возвращается в пул после завершения транзакции переиспользует серверные соединения между клиентами, и именно здесь начинается конфликт: переменная, привязанная к физическому соединению, переживает транзакцию и достаётся следующему клиенту. Разберём по шагам, как PgBouncer возвращает соединение в пул, что он отслеживает в параметрах сессии и почему SET и SET LOCAL ведут себя по-разному.
Как PgBouncer возвращает серверное соединение в пул
Жизненный цикл клиентского соединения начинается с обработки стартового пакета: при получении PKT_STARTUP_V3стартовый пакет протокола PostgreSQL, которым клиент открывает соединение и сообщает параметры подключения PgBouncer либо завершает выбор пула для клиента, который уже ждал пользователя, либо принимает решение о пуле заново. Завершение выбора пула — единая точка схода для всех способов логина: там выставляется флаг, что стартовое сообщение получено, и создаётся пул для пары пользователь/база. После успешной аутентификации клиент переходит в режим обработки пакетов уже залогиненного клиента.
Возврат серверного соединения в пул в режиме transaction происходит после завершения транзакции: сервер переводится в состояние SV_IDLE, и ожидающий клиент активируется из очереди waiting_client_list. Это соответствует документации: «Server is released back to pool after transaction finishes».
Как PgBouncer понимает, что транзакция завершена
Признак завершения транзакции — пакет ReadyForQuery. PgBouncer читает из пакета символ состояния: 'I' означает, что сервер простаивает и готов к переиспользованию; 'T' — что открыт блок транзакции; 'E' — что транзакция завершилась ошибкой. Если состояние не 'I', сервер помечается как находящийся в незавершённой транзакции и не может быть отдан другому клиенту. В режиме POOL_STMTstatement pooling — режим, в котором сервер возвращается в пул после завершения каждого запроса, а транзакции из нескольких операторов запрещены при состоянии, отличном от 'I', сервер принудительно отключается с сообщением «transaction blocks not allowed in statement pooling mode».
Режим пула определяется функцией connection_pool_mode: для репликационных соединений она всегда возвращает POOL_SESSIONsession pooling — режим, в котором сервер закреплён за клиентом до его отключения, иначе делегирует в probably_wrong_pool_pool_mode, где значение pool_mode берётся у глобального пользователя, затем у базы, затем из конфигурации cf_pool_mode; POOL_INHERITзначение, означающее, что режим пула наследуется от следующего уровня настройки.
После того как сервер снова стал ready, release_server отвязывает сервер от клиента, при необходимости переводит его в состояние, в котором серверу перед повторным использованием нужно отправить запрос сброса, если задан reset query и это session pooling, либо в состояние, в котором сервер помечен как использованный и требует проверки перед повторным использованием, а если очередь outstanding_requests непуста — отключает сервер с сообщением «client disconnected with queries in progress». Если сервер переходит в состояние простоя, в котором он свободен и может быть отдан ожидающему клиенту, reuse_on_release немедленно передаёт его первому ожидающему клиенту.
Что именно ломается с RLS
Если политика RLS опирается на current_setting('app.current_tenant_id'), а значение задано обычным SET app.current_tenant = '...', переменная привязывается к физическому соединению. Когда транзакция завершается, PgBouncer возвращает «грязное» соединение в пул, и следующий запрос от другого клиента может получить это соединение и увидеть чужие данные.
Чтобы этого не происходило, значение задают через set_config('app.current_tenant', $1, true): флаг is_local=true говорит PostgreSQL, что переменная живёт только до конца текущей транзакции, и Postgres гарантированно почистит контекст при COMMIT или ROLLBACK. Это делает RLS безопасным для Transaction Pooling. Statement Pooling (pool_mode = statement) абсолютно несовместим с RLS и set_config, потому что транзакция может быть разорвана между разными соединениями и контекст будет потерян или перепутан.
Обёртка RunInTxфункция, выполняющая запрос в транзакции с контекстом тенанта берёт идентификатор тенанта из контекста запроса. Если контекст не установлен, обёртка немедленно останавливает выполнение с паникой, а не продолжает работу: иначе запрос молча вернул бы пустой результат из-за политики, и пропущенное middleware осталось бы незамеченным.
Как ведут себя SET и SET LOCAL
SET LOCAL действует только до конца текущей транзакции: «The effects of SET LOCAL last only till the end of the current transaction, whether committed or not.» После COMMIT или ROLLBACK снова вступает в силу настройка уровня сессии. Вне транзакции SET LOCAL выдаёт предупреждение и иного эффекта не имеет.
Обычный SET меняет параметры только для текущей сессии. В режиме transaction pooling сервер возвращается в пул после завершения транзакции, поэтому обычный SET привязывается к физическому соединению, и после возврата «грязного» соединения следующий запрос другого клиента может увидеть чужие данные. Гарантированно сохраняется только локальный на транзакцию контекст.
Что PgBouncer отслеживает в параметрах сессии
PgBouncer хранит значения параметров сессии в VarCacheхранилище значений отслеживаемых параметров сессии, разложенных по фиксированному списку имён. Набор отслеживаемых имён фиксирован: DateStyle, client_encoding, TimeZone, standard_conforming_strings, application_name, default_transaction_read_only, IntervalStyle, search_path, scram_iterations, session_authorization, плюс дополнительные имена из track_extra_parameters.
Когда клиент задаёт параметр, PgBouncer сохраняет его значение, только если имя входит в список отслеживаемых; параметр с неизвестным именем в хранилище не попадает. При переключении сервера между клиентами PgBouncer сравнивает значения клиента и сервера по каждому отслеживаемому имени и формирует один пакет PqMsg_Query с командами SET/RESET только для отличающихся: если у клиента значения нет, а у сервера есть — пишет RESET key;, иначе SET key=value;. Для search_path значение подставляется как есть, а пустая строка "" превращается в ''.
Значения, не входящие в список отслеживаемых имён, между транзакциями не переносятся. Для параметров, которые сервер не отчитывает через ParameterStatus, значение сервера всегда перезаписывается значением клиента, и обратное копирование не делается.
Как задаётся контекст тенанта в обёртках
В pg-rls-context контекст задаётся через createRlsScope(pool, options), где options.tenantSetting — имя настройки, используемой withTenant, например "app.current_tenant". Метод scope.withContext(context, fn) выполняет fn(client) в транзакции, применяя каждую пару context как transaction-local, а scope.withTenant(tenantId, fn) — сокращение для одного тенанта и требует tenantSetting.
В pg_tenant_rls контекст задаётся через PgTenantRls::Context.with_tenant(tenant.id) do ... end, а GUC настраивается как c.guc = "app.current_tenant_id". Конфигурация процесса глобальна намеренно, потому что контекст тенанта принадлежит сессии базы данных, а не объекту приложения. Работает это в пределах транзакции/сессии, потому что SET LOCAL действует только до конца текущей транзакции, а SET — до конца сессии, если не переопределён.
Отсутствие контекста: тихий NULL или громкая ошибка
Функция current_tenant_strict() возвращает uuid, полученный из current_setting('app.tenant_id')::uuid. При отсутствии установленного GUC приведение к uuid выбросит исключение, которое перехватывается блоком EXCEPTION WHEN OTHERS, после чего клиент получит ошибку «RLS Policy Violation: Security Context Missing». То есть вместо пустой выборки или NULL клиент увидит явную ошибку. Такой режим называется PANIC и рекомендуется для платежей, персональных данных или любых других важных данных: он переводит ошибки безопасности из разряда «тихих багов» в разряд «громких инцидентов», которые чинятся в первые минуты после деплоя.
Для сравнения, в фазе логирования конструкция OR ... IS NULL означает, что при забытом контексте запрос вернёт всю базу, а не ошибку, и без строгого логирования этот режим является дырой в безопасности.
Режимы пула и их совместимость с RLS
Режимы различаются моментом возврата серверного соединения в пул:
- session — «Server is released back to pool after client disconnects. Default.»;
- transaction — «Server is released back to pool after transaction finishes.»;
- statement — «Server is released back to pool after query finishes. Transactions spanning multiple statements are disallowed in this mode.»
SET меняет параметры только для текущей сессии, SET LOCAL действует лишь до конца транзакции. Поэтому в session-режиме соединение закреплено за клиентом до отключения, и контекст, заданный через SET, сохраняется между запросами одного клиента. В transaction-режиме соединение возвращается в пул после завершения транзакции, поэтому обычный SET привязывается к физическому соединению и после возврата «грязного» соединения следующий запрос другого клиента может увидеть чужие данные; гарантированно сохраняется только локальный на транзакцию контекст. Statement-режим для такого контекста не подходит: «Statement Pooling режим (pool_mode = statement) абсолютно несовместим с RLS и set_config.»
Переключение pool_mode на лету
probably_wrong_pool_pool_mode разрешает наследование: сначала берётся pool_mode глобального пользователя, при значении POOL_INHERIT — pool_mode базы, при повторном POOL_INHERIT — cf_pool_mode. connection_pool_mode принудительно возвращает POOL_SESSION для репликационных соединений, иначе делегирует в probably_wrong_pool_pool_mode.
Настройка server_fast_close управляет тем, когда сервер в режиме session pooling закрывается при «close_needed»: немедленно или после текущей транзакции; в режиме statement или transaction это не имеет эффекта, так как там такое поведение по умолчанию.
Исчерпание пула
Сначала PgBouncer пытается найти свободное серверное соединение в списке idle_server_list. Если свободного соединения нет, клиент продолжает работу, когда пул отсутствует, когда последний логин был успешным или когда доступные серверы есть. Если же все серверные соединения были отброшены и новые не создаются, клиент отключается с сообщением об ошибке кэшированного логина. Если серверы есть, но все заняты, клиент не отключается, а ставится в очередь ожидания: он переходит в состояние CL_WAITING, и чтение из его сокета приостанавливается.
Размеры пула ограничены: pool_pool_size задаёт максимум серверных соединений на пару пользователь/база, database_max_connections — лимит на базу, user_max_connections — лимит на пользователя. При достижении этих лимитов новые серверные соединения не создаются, и клиенты ждут в очереди.
Prepared statements в transaction-режиме
PgBouncer перехватывает все запросы, отправленные клиентами как prepared statement, и присваивает каждой уникальной строке запроса внутреннее имя вида PGBOUNCER_{unique_id}; одинаковые строки от разных клиентов получают одно и то же внутреннее имя. На реальном сервере PostgreSQL PgBouncer подготавливает оператор именно под этим внутренним именем, хранит соответствие между именем, которое дал клиент, и внутренним именем, и переписывает каждую команду, использующую prepared statement, заменяя клиентское имя на внутреннее перед отправкой на сервер. Если prepared statement ещё не подготовлен на сервере (например, потому что клиенту назначен другой сервер, чем в момент подготовки), PgBouncer прозрачно подготавливает оператор перед его выполнением.
Такое отслеживание и переписывание не работает для SQL-команд уровня SQL, поэтому PREPARE, EXECUTE и DEALLOCATE пересылаются напрямую в Postgres, за исключением DEALLOCATE ALL и DISCARD ALL, которые очищают отслеживаемые PgBouncer prepared statements для отправившего их клиента. Значение настройки задаёт число prepared statements, хранимых в LRU-кэше на одном серверном соединении; при значении 0 поддержка prepared statements для transaction и statement pooling отключается.
Для RLS-приложений отдельная проблема возникает из-за того, что при transaction pooling обычный SET app.current_tenant = '...' привязывает переменную к физическому соединению, и после завершения транзакции PgBouncer возвращает «грязное» соединение в пул.
Аутентификация через auth_query
Когда клиент проходит аутентификацию, PgBouncer может не хранить пароли у себя, а запрашивать их у PostgreSQL отдельным запросом auth_query. Пока такой запрос выполняется, клиент считается ожидающим, и по этому признаку система понимает, что сейчас идёт отправка auth_query.
Для отправки запроса PgBouncer берёт текст запроса из настроек базы клиента или из глобальной настройки cf_auth_query, определяет базу для аутентификации, берёт пул по учётным данным auth_user_credentials и находит сервер. Затем он приостанавливает буфер клиента, помечает соединение как неготовое, регистрирует незавершённый запрос и отправляет расширенный запрос с одним параметром username.
Ответ сервера разбирается так: на PqMsg_RowDescription требуется ровно 2 колонки, иначе соединение с сервером разрывается; на PqMsg_DataRow также требуется 2 колонки, читаются имя пользователя и пароль, при NULL-имени строка пропускается, а при NULL-пароле подставляется password = "md5"; length = 3;, чтобы ничего не совпало.
Функция pgbouncer.user_lookup объявлена SECURITY DEFINER и с SET search_path = pg_catalog, pg_temp;, а права выданы только роли pgbouncer. Она читает rolname, CASE WHEN rolvaliduntil < now() THEN NULL ELSE rolpassword END FROM pg_authid WHERE rolname=i_username AND rolcanlogin, то есть обращается к pg_authid, поэтому SECURITY DEFINER нужен, чтобы выполнять чтение от имени владельца функции, а не вызывающей роли.
Бэкапы: почему pg_dump под RLS-ролью даёт битые дампы
pg_dump по умолчанию выставляет row_security = off, чтобы выгрузить все строки таблицы без исключения; если у роли не хватает прав, он завершается с ошибкой, а не выгружает часть данных. Права обходить RLS есть у суперпользователя, у роли с атрибутом BYPASSRLS и у владельца таблицы (если не включён FORCE ROW LEVEL SECURITY).
В описанном случае дамп шёл под ролью с именем вида ..._owner, но таблицами владел postgres, атрибута BYPASSRLS у роли не было, и на таблицах были включены политики — дамп упал на первой же такой таблице. Опасность в том, что pg_dump открывает выходной файл в начале и, упав на таблице с политикой, оставляет недописанный файл, поэтому по каталогу бэкапов всё выглядит нормально.
Referential integrity checks, такие как unique или primary key constraints и foreign key references, всегда обходят row security, чтобы обеспечить целостность данных; в контексте бэкапа row_security = off не обходит RLS сам по себе, а выбрасывает ошибку, если результаты запроса были бы отфильтрованы политикой.
Корректный дамп снимался суперпользователем postgres через unix-сокет. Запуск выполнялся через runuser, а не sudo, потому что в юните стоит NoNewPrivileges=true и sudo под ним не работает: runuser вызывается от root и просто переключает пользователя, ему повышение привилегий не нужно. Переменные окружения PGUSER и PGPASSWORD сбрасывались через env -u, чтобы они не подменили пользователя и пароль подключения. Вывод pg_dump шёл в stdout с редиректом, а не через --file, так как каталог бэкапов принадлежит root, а pg_dump работает от имени postgres.
Полнота дампа проверялась несколькими способами: размер вырос более чем в пять раз, совпала контрольная сумма, pg_restore --list прошёл без ошибок, тестовое восстановление в отдельную базу прошло без ошибок, и число строк в восстановленной базе совпало с боевой по каждой сверяемой таблице.
Почему выбирают GUC, а не роли
Авторы обоих решений используют GUC, потому что значение нужно привязать к транзакции, а не к роли: pg-rls-context прямо указывает, что обычный SET «sticking to the connection» опасен с пулером, и лечит это через set_config(name, value, true). pg_tenant_rls фиксирует имя GUC в конфигурации процесса-хоста, потому что «the tenant context belongs to the database session, not to a Ruby object», и предупреждает, что имя GUC «becomes part of your schema» — оно вписано в DEFAULT колонок и предикаты политик.
Отдельные роли на тенанта не годятся ещё и потому, что pg_tenant_rls различает runtime_role (получатель GRANT) и policy_role (TO в политике), причём policy_role по умолчанию nil и «usually should stay there»: TO <role> не добавляет изоляции, зато ломает переносимость и безопасность отказа.
Ограничение на пулер: значение GUC должно быть transaction-local, иначе соединение вернётся в пул «грязным»; поэтому Statement Pooling «абсолютно несовместим с RLS и set_config», допустим только Transaction Pooling. Дополнительно with_tenant запрещает переключение на другого тенанта внутри активного контекста (SwapError), если явно не передан allow_swap: true.
Меры совместимости RLS с PgBouncer
Для совместимости RLS с PgBouncer в режиме Transaction Pooling предлагается использовать третий аргумент set_config с флагом is_local=true: вызов set_config('app.current_tenant', $1, true) привязывает переменную контекста тенанта только к текущей транзакции, и Postgres гарантированно очищает контекст при COMMIT или ROLLBACK, поэтому даже если PgBouncer вернёт соединение в пул, следующий клиент не увидит чужие данные.
Для защиты от отсутствия контекста предлагается функция current_tenant_strict(), которая читает current_setting('app.tenant_id') и при любой ошибке выбрасывает исключение «RLS Policy Violation: Security Context Missing», то есть вместо тихой пустой выборки клиент получает явную ошибку; её советуют применять для платежей и персональных данных. Для обычных бизнес-данных рекомендуется STRICT, а для публичных каталогов и аналитики — LAX.
Для представлений поверх RLS-таблиц требуется security_barrier: пример CREATE VIEW public_docs_view WITH (security_barrier) AS SELECT ... FROM documents WHERE is_public = true, поскольку «любые View поверх RLS-таблиц должны быть с security_barrier. Иначе это решето». LEAKPROOF-функции нужны для производительности: safe_title_search объявлена LANGUAGE sql STABLE LEAKPROOF, что «говорит базе: "Мамой клянусь, эта функция безопасна"» и возвращает Index Scan вместо SeqScan.
Что из этого следует на практике
- В transaction-режиме сервер возвращается в пул сразу после
ReadyForQueryсо состоянием'I', поэтому любой контекст, привязанный к соединению обычнымSET, переживёт транзакцию и достанется следующему клиенту. Контекст тенанта задавайте только черезset_config(..., true)— тогда Postgres сам почистит его приCOMMITилиROLLBACK. SET LOCALвне транзакции не работает: он выдаёт предупреждение и не имеет эффекта. Весь блок работы с тенантом должен быть в одной транзакции на одном соединении.- Statement pooling для RLS не подходит: транзакция разрывается между соединениями, контекст теряется или путается. Допустим только transaction pooling.
- PgBouncer отслеживает лишь фиксированный список параметров сессии плюс
track_extra_parameters. Всё, что не попало вlookup_map, между транзакциями не переносится и не восстанавливается. - Отсутствие контекста должно быть громким:
current_tenant_strict()превращает забытый GUC в ошибку вместо пустой выборки, а конструкцияOR ... IS NULLбез строгого логирования при забытом контексте вернёт всю базу. pg_dumpпод ролью безBYPASSRLSи без владения таблицами падает на первой таблице с политикой, оставляя недописанный файл. Снимайте дамп суперпользователем через unix-сокет и проверяйте полноту: размер, контрольную сумму,pg_restore --list, тестовое восстановление и сверку числа строк.- Prepared statements в transaction-режиме переписываются на внутренние имена
PGBOUNCER_{unique_id}, а SQL-командыPREPARE/EXECUTE/DEALLOCATEуходят напрямую; при значении настройки 0 поддержка prepared statements для transaction и statement pooling отключается.
Где смотреть в коде
- objects.c: release_server
- varcache.c: init_var_lookup
- objects.c: clear_outstanding_requests_until
- varcache.c: variable_is_guc_list_quote
- client.c: sending_auth_query
- objects.c: check_fast_fail
- objects.c: pop_outstanding_request
- objects.c: reuse_on_release